修复部分功能
This commit is contained in:
+21
-16
@@ -17,8 +17,8 @@ type DatabaseConfig struct {
|
|||||||
|
|
||||||
// StorageConfig holds file storage paths.
|
// StorageConfig holds file storage paths.
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
BaseDir string `toml:"base_dir"`
|
BaseDir string `toml:"base_dir"`
|
||||||
AttachDir string `toml:"attach_dir"`
|
AttachDir string `toml:"attach_dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebConfig holds web server settings.
|
// WebConfig holds web server settings.
|
||||||
@@ -28,12 +28,13 @@ type WebConfig struct {
|
|||||||
|
|
||||||
// SMTPConfig holds SMTP server settings.
|
// SMTPConfig holds SMTP server settings.
|
||||||
type SMTPConfig struct {
|
type SMTPConfig struct {
|
||||||
Addr string `toml:"addr"`
|
Addr string `toml:"addr"`
|
||||||
TLSAddr string `toml:"tls_addr"`
|
TLSAddr string `toml:"tls_addr"`
|
||||||
Domain string `toml:"domain"`
|
SubmissionAddr string `toml:"submission_addr"`
|
||||||
TLSCert string `toml:"tls_cert"`
|
Domain string `toml:"domain"`
|
||||||
TLSKey string `toml:"tls_key"`
|
TLSCert string `toml:"tls_cert"`
|
||||||
MaxMessage int64 `toml:"max_message_bytes"`
|
TLSKey string `toml:"tls_key"`
|
||||||
|
MaxMessage int64 `toml:"max_message_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMAPConfig holds IMAP server settings.
|
// IMAPConfig holds IMAP server settings.
|
||||||
@@ -56,15 +57,15 @@ type POP3Config struct {
|
|||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
// OAuth2 configuration
|
// OAuth2 configuration
|
||||||
OAuth2Enabled bool `toml:"oauth2_enabled"`
|
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"`
|
OAuth2ClientID string `toml:"oauth2_client_id"`
|
||||||
OAuth2ClientSecret string `toml:"oauth2_client_secret"`
|
OAuth2ClientSecret string `toml:"oauth2_client_secret"`
|
||||||
OAuth2RedirectURL string `toml:"oauth2_redirect_url"`
|
OAuth2RedirectURL string `toml:"oauth2_redirect_url"`
|
||||||
|
|
||||||
// LDAP configuration
|
// LDAP configuration
|
||||||
LDAPEnabled bool `toml:"ldap_enabled"`
|
LDAPEnabled bool `toml:"ldap_enabled"`
|
||||||
LDAPServer string `toml:"ldap_server"` // e.g. ldap://localhost:389
|
LDAPServer string `toml:"ldap_server"` // e.g. ldap://localhost:389
|
||||||
LDAPBindDN string `toml:"ldap_bind_dn"` // e.g. cn=admin,dc=example,dc=com
|
LDAPBindDN string `toml:"ldap_bind_dn"` // e.g. cn=admin,dc=example,dc=com
|
||||||
LDAPBindPassword string `toml:"ldap_bind_password"`
|
LDAPBindPassword string `toml:"ldap_bind_password"`
|
||||||
LDAPSearchBase string `toml:"ldap_search_base"` // e.g. ou=users,dc=example,dc=com
|
LDAPSearchBase string `toml:"ldap_search_base"` // e.g. ou=users,dc=example,dc=com
|
||||||
LDAPSearchFilter string `toml:"ldap_search_filter"` // e.g. (uid=%s)
|
LDAPSearchFilter string `toml:"ldap_search_filter"` // e.g. (uid=%s)
|
||||||
@@ -134,10 +135,11 @@ func defaultConfig() *Config {
|
|||||||
Addr: DefaultWebPort,
|
Addr: DefaultWebPort,
|
||||||
},
|
},
|
||||||
SMTP: SMTPConfig{
|
SMTP: SMTPConfig{
|
||||||
Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
|
Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
|
||||||
TLSAddr: fmt.Sprintf(":%d", DefaultSMTPTLSPort),
|
TLSAddr: fmt.Sprintf(":%d", DefaultSMTPTLSPort),
|
||||||
Domain: "localhost",
|
SubmissionAddr: fmt.Sprintf(":%d", DefaultSMTPSubmitPort),
|
||||||
MaxMessage: 64 * 1024 * 1024, // 64MB
|
Domain: "localhost",
|
||||||
|
MaxMessage: 64 * 1024 * 1024, // 64MB
|
||||||
},
|
},
|
||||||
IMAP: IMAPConfig{
|
IMAP: IMAPConfig{
|
||||||
Addr: fmt.Sprintf(":%d", DefaultIMAPPort),
|
Addr: fmt.Sprintf(":%d", DefaultIMAPPort),
|
||||||
@@ -186,6 +188,9 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
|
|||||||
if cfg.SMTP.TLSAddr == "" {
|
if cfg.SMTP.TLSAddr == "" {
|
||||||
cfg.SMTP.TLSAddr = defaults.SMTP.TLSAddr
|
cfg.SMTP.TLSAddr = defaults.SMTP.TLSAddr
|
||||||
}
|
}
|
||||||
|
if cfg.SMTP.SubmissionAddr == "" {
|
||||||
|
cfg.SMTP.SubmissionAddr = defaults.SMTP.SubmissionAddr
|
||||||
|
}
|
||||||
if cfg.SMTP.Domain == "" {
|
if cfg.SMTP.Domain == "" {
|
||||||
cfg.SMTP.Domain = defaults.SMTP.Domain
|
cfg.SMTP.Domain = defaults.SMTP.Domain
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -2,8 +2,8 @@ package config
|
|||||||
|
|
||||||
// Linux path prefixes
|
// Linux path prefixes
|
||||||
const (
|
const (
|
||||||
LinuxEtcDir = "/etc/mail_go/"
|
LinuxEtcDir = "/etc/mail_go/"
|
||||||
LinuxBaseDir = "/srv/mail_go/"
|
LinuxBaseDir = "/srv/mail_go/"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Windows path prefixes
|
// Windows path prefixes
|
||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
const (
|
const (
|
||||||
DefaultSMTPPort = 25
|
DefaultSMTPPort = 25
|
||||||
DefaultSMTPTLSPort = 465
|
DefaultSMTPTLSPort = 465
|
||||||
|
DefaultSMTPSubmitPort = 587
|
||||||
DefaultIMAPPort = 143
|
DefaultIMAPPort = 143
|
||||||
DefaultIMAPTLSPort = 993
|
DefaultIMAPTLSPort = 993
|
||||||
DefaultPOP3Port = 110
|
DefaultPOP3Port = 110
|
||||||
|
|||||||
+126
-102
@@ -1,6 +1,7 @@
|
|||||||
package imap_server
|
package imap_server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -15,7 +16,9 @@ import (
|
|||||||
|
|
||||||
"github.com/emersion/go-imap"
|
"github.com/emersion/go-imap"
|
||||||
"github.com/emersion/go-imap/backend"
|
"github.com/emersion/go-imap/backend"
|
||||||
|
"github.com/emersion/go-imap/backend/backendutil"
|
||||||
asgomail "github.com/emersion/go-message/mail"
|
asgomail "github.com/emersion/go-message/mail"
|
||||||
|
"github.com/emersion/go-message/textproto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- imapBackend ----------
|
// ---------- imapBackend ----------
|
||||||
@@ -67,7 +70,7 @@ func (u *imapUser) ListMailboxes(subscribed bool) ([]backend.Mailbox, error) {
|
|||||||
attributes []string
|
attributes []string
|
||||||
}{
|
}{
|
||||||
{"INBOX", "/", nil},
|
{"INBOX", "/", nil},
|
||||||
{"Sent", "/", nil},
|
{"Sent", "/", []string{"\\Sent"}},
|
||||||
{"Drafts", "/", []string{"\\Drafts"}},
|
{"Drafts", "/", []string{"\\Drafts"}},
|
||||||
{"Trash", "/", []string{"\\Trash"}},
|
{"Trash", "/", []string{"\\Trash"}},
|
||||||
}
|
}
|
||||||
@@ -87,25 +90,8 @@ func (u *imapUser) ListMailboxes(subscribed bool) ([]backend.Mailbox, error) {
|
|||||||
|
|
||||||
// GetMailbox returns a mailbox by name.
|
// GetMailbox returns a mailbox by name.
|
||||||
func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) {
|
func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) {
|
||||||
validNames := map[string]bool{
|
normalized, ok := canonicalMailboxName(name)
|
||||||
"INBOX": true,
|
if !ok {
|
||||||
"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] {
|
|
||||||
return nil, backend.ErrNoSuchMailbox
|
return nil, backend.ErrNoSuchMailbox
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +103,21 @@ func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) {
|
|||||||
}, nil
|
}, 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).
|
// CreateMailbox creates a new mailbox (not supported in this version).
|
||||||
func (u *imapUser) CreateMailbox(name string) error {
|
func (u *imapUser) CreateMailbox(name string) error {
|
||||||
return fmt.Errorf("mailbox creation not supported")
|
return fmt.Errorf("mailbox creation not supported")
|
||||||
@@ -170,11 +171,9 @@ func (m *imapMailbox) Info() (*imap.MailboxInfo, error) {
|
|||||||
|
|
||||||
// Status returns mailbox status information.
|
// Status returns mailbox status information.
|
||||||
func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
|
func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
|
||||||
status := &imap.MailboxStatus{
|
status := imap.NewMailboxStatus(m.name, items)
|
||||||
Name: m.name,
|
status.Flags = []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"}
|
||||||
Flags: []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"},
|
status.PermanentFlags = []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft", "\\*"}
|
||||||
PermanentFlags: []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft", "\\*"},
|
|
||||||
}
|
|
||||||
|
|
||||||
messages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
messages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,7 +190,11 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro
|
|||||||
}
|
}
|
||||||
status.Unseen = unseenCount
|
status.Unseen = unseenCount
|
||||||
status.Recent = 0
|
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
|
status.UidValidity = 1
|
||||||
|
|
||||||
return status, nil
|
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.
|
// 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) {
|
func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items []imap.FetchItem) (*imap.Message, error) {
|
||||||
imapMsg := &imap.Message{
|
imapMsg := imap.NewMessage(seqNum, items)
|
||||||
SeqNum: seqNum,
|
imapMsg.Uid = uint32(dbMsg.ID)
|
||||||
Uid: uint32(dbMsg.ID),
|
imapMsg.Flags = m.getMessageFlags(dbMsg)
|
||||||
Flags: m.getMessageFlags(dbMsg),
|
imapMsg.InternalDate = dbMsg.Date
|
||||||
InternalDate: dbMsg.Date,
|
|
||||||
Body: make(map[*imap.BodySectionName]imap.Literal),
|
|
||||||
}
|
|
||||||
|
|
||||||
rawMsg := buildRawMessage(dbMsg)
|
rawMsg := messageRawData(dbMsg)
|
||||||
imapMsg.Size = uint32(len(rawMsg))
|
imapMsg.Size = uint32(len(rawMsg))
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
switch item {
|
switch item {
|
||||||
case imap.FetchUid:
|
case imap.FetchUid, imap.FetchFlags, imap.FetchInternalDate, imap.FetchRFC822Size:
|
||||||
// UID is already set on the struct
|
continue
|
||||||
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.FetchEnvelope:
|
case imap.FetchEnvelope:
|
||||||
imapMsg.Envelope = m.buildEnvelope(dbMsg)
|
hdr, _, err := headerAndBody(rawMsg)
|
||||||
case imap.FetchRFC822:
|
if err == nil {
|
||||||
section := &imap.BodySectionName{BodyPartName: imap.BodyPartName{}}
|
imapMsg.Envelope, _ = backendutil.FetchEnvelope(hdr)
|
||||||
imapMsg.Body[section] = bytes.NewReader(rawMsg)
|
|
||||||
case imap.FetchRFC822Header:
|
|
||||||
headerBytes := buildRawHeader(dbMsg)
|
|
||||||
section := &imap.BodySectionName{
|
|
||||||
BodyPartName: imap.BodyPartName{
|
|
||||||
Specifier: imap.HeaderSpecifier,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
imapMsg.Body[section] = bytes.NewReader(headerBytes)
|
if imapMsg.Envelope == nil {
|
||||||
case imap.FetchRFC822Text:
|
imapMsg.Envelope = m.buildEnvelope(dbMsg)
|
||||||
var bodyText string
|
|
||||||
if dbMsg.TextBody != "" {
|
|
||||||
bodyText = dbMsg.TextBody
|
|
||||||
} else if dbMsg.HtmlBody != "" {
|
|
||||||
bodyText = dbMsg.HtmlBody
|
|
||||||
}
|
}
|
||||||
section := &imap.BodySectionName{
|
case imap.FetchBody, imap.FetchBodyStructure:
|
||||||
BodyPartName: imap.BodyPartName{
|
hdr, body, err := headerAndBody(rawMsg)
|
||||||
Specifier: imap.TextSpecifier,
|
if err == nil {
|
||||||
},
|
imapMsg.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure)
|
||||||
}
|
}
|
||||||
imapMsg.Body[section] = bytes.NewReader([]byte(bodyText))
|
|
||||||
default:
|
default:
|
||||||
// Handle BODY[] and BODY.PEEK[] sections
|
section, err := imap.ParseBodySectionName(item)
|
||||||
itemStr := string(item)
|
if err != nil {
|
||||||
if strings.HasPrefix(itemStr, "BODY[]") || strings.HasPrefix(itemStr, "BODY.PEEK[]") {
|
continue
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
hdr, body, err := headerAndBody(rawMsg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
literal, _ := backendutil.FetchBodySection(hdr, body, section)
|
||||||
|
imapMsg.Body[section] = literal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return imapMsg, nil
|
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.
|
// getMessageFlags returns IMAP flags for a database message.
|
||||||
func (m *imapMailbox) getMessageFlags(dbMsg *db.Message) []string {
|
func (m *imapMailbox) getMessageFlags(dbMsg *db.Message) []string {
|
||||||
flags := make([]string, 0)
|
flags := make([]string, 0)
|
||||||
@@ -607,28 +584,40 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flagSet := make(map[string]bool, len(flags))
|
||||||
for _, flag := range flags {
|
for _, flag := range flags {
|
||||||
|
flagSet[flag] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFlag := func(flag string, enabled bool) {
|
||||||
switch flag {
|
switch flag {
|
||||||
case "\\Seen":
|
case "\\Seen":
|
||||||
if op == imap.AddFlags || op == imap.SetFlags {
|
_ = m.stores.Mails.MarkReadState(dbMsg.ID, enabled)
|
||||||
_ = m.stores.Mails.MarkRead(dbMsg.ID)
|
|
||||||
} else if op == imap.RemoveFlags {
|
|
||||||
// Mark as unread — update directly
|
|
||||||
}
|
|
||||||
case "\\Flagged":
|
case "\\Flagged":
|
||||||
if op == imap.AddFlags || op == imap.SetFlags {
|
_ = m.stores.Mails.MarkFlagged(dbMsg.ID, enabled)
|
||||||
_ = m.stores.Mails.MarkFlagged(dbMsg.ID, true)
|
|
||||||
} else if op == imap.RemoveFlags {
|
|
||||||
_ = m.stores.Mails.MarkFlagged(dbMsg.ID, false)
|
|
||||||
}
|
|
||||||
case "\\Deleted":
|
case "\\Deleted":
|
||||||
if op == imap.AddFlags || op == imap.SetFlags {
|
if enabled {
|
||||||
m.deleted[dbMsg.ID] = true
|
m.deleted[dbMsg.ID] = true
|
||||||
} else if op == imap.RemoveFlags {
|
} else {
|
||||||
delete(m.deleted, dbMsg.ID)
|
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
|
return nil
|
||||||
@@ -636,6 +625,11 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap
|
|||||||
|
|
||||||
// CopyMessages copies messages to another mailbox.
|
// CopyMessages copies messages to another mailbox.
|
||||||
func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error {
|
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)
|
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -663,6 +657,7 @@ func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) e
|
|||||||
Subject: dbMsg.Subject,
|
Subject: dbMsg.Subject,
|
||||||
TextBody: dbMsg.TextBody,
|
TextBody: dbMsg.TextBody,
|
||||||
HtmlBody: dbMsg.HtmlBody,
|
HtmlBody: dbMsg.HtmlBody,
|
||||||
|
RawData: dbMsg.RawData,
|
||||||
IsRead: dbMsg.IsRead,
|
IsRead: dbMsg.IsRead,
|
||||||
IsFlagged: dbMsg.IsFlagged,
|
IsFlagged: dbMsg.IsFlagged,
|
||||||
Date: dbMsg.Date,
|
Date: dbMsg.Date,
|
||||||
@@ -675,6 +670,35 @@ func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) e
|
|||||||
return nil
|
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.
|
// Expunge permanently removes messages marked as \Deleted.
|
||||||
func (m *imapMailbox) Expunge() error {
|
func (m *imapMailbox) Expunge() error {
|
||||||
if m.deleted == nil {
|
if m.deleted == nil {
|
||||||
|
|||||||
@@ -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.
|
// 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}
|
be := &imapBackend{stores: s.stores}
|
||||||
srv := imapserver.New(be)
|
srv := imapserver.New(be)
|
||||||
srv.Addr = addr
|
srv.Addr = addr
|
||||||
srv.AllowInsecureAuth = true
|
srv.TLSConfig = tlsConfig
|
||||||
|
srv.AllowInsecureAuth = tlsConfig == nil
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the IMAP server on the plain-text port.
|
// Start starts the IMAP server on the plain-text port.
|
||||||
func (s *IMAPServer) Start() error {
|
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)
|
log.Printf("IMAP server listening on %s", s.cfg.Addr)
|
||||||
return srv.ListenAndServe()
|
return srv.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTLS starts the IMAP server on the TLS port.
|
// StartTLS starts the IMAP server on the TLS port.
|
||||||
func (s *IMAPServer) StartTLS() error {
|
func (s *IMAPServer) StartTLS() error {
|
||||||
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
|
tlsConfig, err := s.tlsConfig()
|
||||||
return fmt.Errorf("IMAP TLS certificate or key not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load IMAP TLS certificate: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := s.newServer(s.cfg.TLSAddr)
|
srv := s.newServer(s.cfg.TLSAddr, tlsConfig)
|
||||||
srv.TLSConfig = &tls.Config{
|
|
||||||
Certificates: []tls.Certificate{cert},
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("IMAPS server listening on %s", s.cfg.TLSAddr)
|
log.Printf("IMAPS server listening on %s", s.cfg.TLSAddr)
|
||||||
return srv.ListenAndServeTLS()
|
return srv.ListenAndServeTLS()
|
||||||
|
|||||||
+150
-129
@@ -26,10 +26,18 @@ type POP3Server struct {
|
|||||||
|
|
||||||
// NewPOP3Server creates a new POP3 server instance.
|
// NewPOP3Server creates a new POP3 server instance.
|
||||||
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores) *POP3Server {
|
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores) *POP3Server {
|
||||||
return &POP3Server{
|
return &POP3Server{stores: stores, cfg: cfg}
|
||||||
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.
|
// Start starts the POP3 server on the configured plain-text port.
|
||||||
@@ -48,7 +56,6 @@ func (s *POP3Server) Start() error {
|
|||||||
for {
|
for {
|
||||||
conn, err := s.listener.Accept()
|
conn, err := s.listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Listener closed
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.wg.Add(1)
|
s.wg.Add(1)
|
||||||
@@ -64,16 +71,12 @@ func (s *POP3Server) Start() error {
|
|||||||
|
|
||||||
// StartTLS starts the POP3 server on the configured TLS port.
|
// StartTLS starts the POP3 server on the configured TLS port.
|
||||||
func (s *POP3Server) StartTLS() error {
|
func (s *POP3Server) StartTLS() error {
|
||||||
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
|
tlsConfig, err := s.tlsConfig()
|
||||||
return fmt.Errorf("POP3 TLS certificate or key not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
|
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("POP3 TLS listen failed: %w", err)
|
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.
|
// handleConn handles a single POP3 client connection.
|
||||||
func (s *POP3Server) handleConn(conn net.Conn) {
|
func (s *POP3Server) handleConn(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// Set read/write deadlines
|
|
||||||
conn.SetDeadline(time.Now().Add(10 * time.Minute))
|
conn.SetDeadline(time.Now().Add(10 * time.Minute))
|
||||||
|
|
||||||
reader := bufio.NewReader(conn)
|
reader := bufio.NewReader(conn)
|
||||||
|
|
||||||
// Session state
|
|
||||||
var user *db.User
|
var user *db.User
|
||||||
var messages []pop3Message
|
var messages []pop3Message
|
||||||
var deleted map[int]bool
|
var deleted map[int]bool
|
||||||
|
tlsActive := false
|
||||||
|
|
||||||
// Send greeting
|
|
||||||
sendResponse(conn, "+OK MailGo POP3 server ready")
|
sendResponse(conn, "+OK MailGo POP3 server ready")
|
||||||
|
|
||||||
// Main command loop
|
|
||||||
for {
|
for {
|
||||||
line, err := reader.ReadString('\n')
|
line, err := reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return // connection closed or error
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
@@ -128,7 +126,6 @@ func (s *POP3Server) handleConn(conn net.Conn) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse command
|
|
||||||
parts := strings.SplitN(line, " ", 2)
|
parts := strings.SplitN(line, " ", 2)
|
||||||
cmd := strings.ToUpper(parts[0])
|
cmd := strings.ToUpper(parts[0])
|
||||||
arg := ""
|
arg := ""
|
||||||
@@ -136,6 +133,12 @@ func (s *POP3Server) handleConn(conn net.Conn) {
|
|||||||
arg = strings.TrimSpace(parts[1])
|
arg = strings.TrimSpace(parts[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authenticated := user != nil && user.ID != 0
|
||||||
|
if !authenticated && requiresAuth(cmd) {
|
||||||
|
sendResponse(conn, "-ERR authentication required")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "USER":
|
case "USER":
|
||||||
user, messages, deleted = s.handleUSER(conn, arg, user)
|
user, messages, deleted = s.handleUSER(conn, arg, user)
|
||||||
@@ -148,21 +151,40 @@ func (s *POP3Server) handleConn(conn net.Conn) {
|
|||||||
case "RETR":
|
case "RETR":
|
||||||
s.handleRETR(conn, arg, messages, deleted)
|
s.handleRETR(conn, arg, messages, deleted)
|
||||||
case "DELE":
|
case "DELE":
|
||||||
s.handleDELE(conn, arg, deleted)
|
s.handleDELE(conn, arg, messages, deleted)
|
||||||
case "NOOP":
|
case "NOOP":
|
||||||
sendResponse(conn, "+OK")
|
sendResponse(conn, "+OK")
|
||||||
case "RSET":
|
case "RSET":
|
||||||
deleted = make(map[int]bool)
|
deleted = make(map[int]bool)
|
||||||
sendResponse(conn, "+OK")
|
sendResponse(conn, "+OK")
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
// In UPDATE state, actually delete marked messages
|
|
||||||
s.expungeDeleted(messages, deleted, user)
|
s.expungeDeleted(messages, deleted, user)
|
||||||
sendResponse(conn, "+OK MailGo POP3 server signing off")
|
sendResponse(conn, "+OK MailGo POP3 server signing off")
|
||||||
return
|
return
|
||||||
case "CAPA":
|
case "CAPA":
|
||||||
sendResponse(conn, "+OK Capability list follows")
|
s.handleCAPA(conn, tlsActive)
|
||||||
sendResponse(conn, "USER")
|
case "STLS":
|
||||||
sendResponse(conn, ".")
|
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":
|
case "TOP":
|
||||||
s.handleTOP(conn, arg, messages, deleted)
|
s.handleTOP(conn, arg, messages, deleted)
|
||||||
case "UIDL":
|
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.
|
// pop3Message holds a message and its computed size for POP3.
|
||||||
type pop3Message struct {
|
type pop3Message struct {
|
||||||
id uint
|
id uint
|
||||||
@@ -198,7 +241,7 @@ func (s *POP3Server) loadMessages(user *db.User) []pop3Message {
|
|||||||
msgs = append(msgs, pop3Message{
|
msgs = append(msgs, pop3Message{
|
||||||
id: dbMsgs[i].ID,
|
id: dbMsgs[i].ID,
|
||||||
raw: raw,
|
raw: raw,
|
||||||
size: len(raw),
|
size: len(normalizePOP3Data(raw)),
|
||||||
message: &dbMsgs[i],
|
message: &dbMsgs[i],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -212,12 +255,9 @@ func (s *POP3Server) handleUSER(conn net.Conn, username string, currentUser *db.
|
|||||||
return currentUser, nil, nil
|
return currentUser, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the user by email
|
|
||||||
user, err := s.stores.Users.GetByEmail(username)
|
user, err := s.stores.Users.GetByEmail(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Don't reveal whether user exists yet (standard POP3 behavior)
|
|
||||||
sendResponse(conn, "+OK")
|
sendResponse(conn, "+OK")
|
||||||
// Store the attempted username for PASS to use
|
|
||||||
return &db.User{Username: username}, nil, nil
|
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
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate
|
|
||||||
authUser, err := s.stores.Users.Authenticate(user.Username, password)
|
authUser, err := s.stores.Users.Authenticate(user.Username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If username was an email, try using it directly
|
sendResponse(conn, "-ERR authentication failed")
|
||||||
if strings.Contains(user.Username, "@") {
|
return nil, nil, nil
|
||||||
authUser, err = s.stores.Users.Authenticate(user.Username, password)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
sendResponse(conn, "-ERR authentication failed")
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load messages
|
|
||||||
messages := s.loadMessages(authUser)
|
messages := s.loadMessages(authUser)
|
||||||
deleted := make(map[int]bool)
|
deleted := make(map[int]bool)
|
||||||
|
|
||||||
sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages)))
|
sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages)))
|
||||||
return authUser, messages, deleted
|
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).
|
// handleLIST processes the LIST command (with optional message number).
|
||||||
func (s *POP3Server) handleLIST(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
func (s *POP3Server) handleLIST(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
||||||
if arg == "" {
|
if arg == "" {
|
||||||
// List all messages
|
|
||||||
sendResponse(conn, "+OK message list follows")
|
sendResponse(conn, "+OK message list follows")
|
||||||
for i, msg := range messages {
|
for i, msg := range messages {
|
||||||
if !deleted[i+1] {
|
if !deleted[i+1] {
|
||||||
@@ -277,19 +307,19 @@ func (s *POP3Server) handleLIST(conn net.Conn, arg string, messages []pop3Messag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendResponse(conn, ".")
|
sendResponse(conn, ".")
|
||||||
} else {
|
return
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// handleRETR processes the RETR command.
|
||||||
@@ -306,29 +336,17 @@ func (s *POP3Server) handleRETR(conn net.Conn, arg string, messages []pop3Messag
|
|||||||
|
|
||||||
msg := messages[num-1]
|
msg := messages[num-1]
|
||||||
sendResponse(conn, fmt.Sprintf("+OK %d octets", msg.size))
|
sendResponse(conn, fmt.Sprintf("+OK %d octets", msg.size))
|
||||||
|
writeDotStuffed(conn, msg.raw, nil)
|
||||||
// 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"))
|
|
||||||
}
|
|
||||||
conn.Write([]byte(".\r\n"))
|
conn.Write([]byte(".\r\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDELE processes the DELE command.
|
// 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)
|
num, err := strconv.Atoi(arg)
|
||||||
if err != nil || num < 1 {
|
if err != nil || num < 1 || num > len(messages) {
|
||||||
sendResponse(conn, "-ERR invalid message number")
|
sendResponse(conn, "-ERR no such message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if deleted == nil {
|
|
||||||
deleted = make(map[int]bool)
|
|
||||||
}
|
|
||||||
if deleted[num] {
|
if deleted[num] {
|
||||||
sendResponse(conn, "-ERR message already deleted")
|
sendResponse(conn, "-ERR message already deleted")
|
||||||
return
|
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).
|
// 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) {
|
func (s *POP3Server) handleTOP(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
||||||
// Format: TOP msgNum [n]
|
|
||||||
parts := strings.Fields(arg)
|
parts := strings.Fields(arg)
|
||||||
if len(parts) < 1 {
|
if len(parts) < 1 {
|
||||||
sendResponse(conn, "-ERR syntax error")
|
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")
|
sendResponse(conn, "-ERR no such message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if deleted != nil && deleted[num] {
|
if deleted[num] {
|
||||||
sendResponse(conn, "-ERR message deleted")
|
sendResponse(conn, "-ERR message deleted")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -362,79 +379,42 @@ func (s *POP3Server) handleTOP(conn net.Conn, arg string, messages []pop3Message
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := messages[num-1]
|
msg := messages[num-1]
|
||||||
|
header, body := splitHeaderBody(msg.raw)
|
||||||
sendResponse(conn, "+OK top of message follows")
|
sendResponse(conn, "+OK top of message follows")
|
||||||
|
writeDotStuffed(conn, header, nil)
|
||||||
// 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"))
|
|
||||||
}
|
|
||||||
conn.Write([]byte("\r\n"))
|
conn.Write([]byte("\r\n"))
|
||||||
|
writeDotStuffed(conn, body, &nLines)
|
||||||
// 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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.Write([]byte(".\r\n"))
|
conn.Write([]byte(".\r\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUIDL processes the UIDL command.
|
// handleUIDL processes the UIDL command.
|
||||||
func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
||||||
if arg == "" {
|
if arg == "" {
|
||||||
// List all UIDs
|
|
||||||
sendResponse(conn, "+OK UIDL list follows")
|
sendResponse(conn, "+OK UIDL list follows")
|
||||||
for i, msg := range messages {
|
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, fmt.Sprintf("%d %d", i+1, msg.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendResponse(conn, ".")
|
sendResponse(conn, ".")
|
||||||
} else {
|
return
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// expungeDeleted actually deletes messages that were marked for deletion.
|
||||||
func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) {
|
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
|
return
|
||||||
}
|
}
|
||||||
for seqNum, msgDeleted := range deleted {
|
for seqNum, msgDeleted := range deleted {
|
||||||
@@ -451,11 +431,53 @@ func sendResponse(conn net.Conn, line string) {
|
|||||||
conn.Write([]byte(line + "\r\n"))
|
conn.Write([]byte(line + "\r\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRawMessage reconstructs a raw RFC822 message from a db.Message.
|
func normalizePOP3Data(raw string) string {
|
||||||
// This is a local copy to avoid importing from the imap_server package.
|
var b strings.Builder
|
||||||
func buildRawMessage(msg *db.Message) []byte {
|
writeDotStuffed(&b, raw, nil)
|
||||||
var buf strings.Builder
|
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("From: %s\r\n", msg.FromAddr))
|
||||||
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
|
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
|
||||||
if msg.CcAddr != "" {
|
if msg.CcAddr != "" {
|
||||||
@@ -490,6 +512,5 @@ func buildRawMessage(msg *db.Message) []byte {
|
|||||||
} else {
|
} else {
|
||||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return []byte(buf.String())
|
return []byte(buf.String())
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-156
@@ -12,16 +12,24 @@ import (
|
|||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/mailutil"
|
"mail_go/internal/mailutil"
|
||||||
"mail_go/internal/store"
|
|
||||||
"mail_go/internal/storage"
|
"mail_go/internal/storage"
|
||||||
|
"mail_go/internal/store"
|
||||||
|
|
||||||
"github.com/emersion/go-message/mail"
|
"github.com/emersion/go-message/mail"
|
||||||
|
"github.com/emersion/go-sasl"
|
||||||
"github.com/emersion/go-smtp"
|
"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 {
|
type SMTPServer struct {
|
||||||
server *smtp.Server
|
|
||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
storage *storage.AttachmentStorage
|
storage *storage.AttachmentStorage
|
||||||
cfg config.SMTPConfig
|
cfg config.SMTPConfig
|
||||||
@@ -29,161 +37,228 @@ type SMTPServer struct {
|
|||||||
|
|
||||||
// NewSMTPServer creates a new SMTP server instance.
|
// NewSMTPServer creates a new SMTP server instance.
|
||||||
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *SMTPServer {
|
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *SMTPServer {
|
||||||
s := &SMTPServer{
|
return &SMTPServer{stores: stores, storage: attStorage, cfg: cfg}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the SMTP server on the plain-text port.
|
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
|
||||||
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 {
|
|
||||||
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
|
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)
|
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
|
||||||
if err != nil {
|
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 端口
|
func (s *SMTPServer) newServer(addr string, mode smtpMode, tlsConfig *tls.Config) *smtp.Server {
|
||||||
be := &smtpBackend{server: s}
|
be := &smtpBackend{server: s, mode: mode}
|
||||||
srv := smtp.NewServer(be)
|
srv := smtp.NewServer(be)
|
||||||
srv.Addr = s.cfg.TLSAddr
|
srv.Addr = addr
|
||||||
srv.Domain = s.cfg.Domain
|
srv.Domain = s.cfg.Domain
|
||||||
srv.MaxMessageBytes = s.cfg.MaxMessage
|
srv.MaxMessageBytes = s.cfg.MaxMessage
|
||||||
srv.AllowInsecureAuth = false
|
srv.AllowInsecureAuth = tlsConfig == nil
|
||||||
srv.ReadTimeout = 60 * time.Second
|
srv.ReadTimeout = 60 * time.Second
|
||||||
srv.WriteTimeout = 60 * time.Second
|
srv.WriteTimeout = 60 * time.Second
|
||||||
srv.TLSConfig = &tls.Config{
|
srv.TLSConfig = tlsConfig
|
||||||
Certificates: []tls.Certificate{cert},
|
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)
|
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.
|
// smtpBackend implements the smtp.Backend interface.
|
||||||
type smtpBackend struct {
|
type smtpBackend struct {
|
||||||
server *SMTPServer
|
server *SMTPServer
|
||||||
|
mode smtpMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSession creates a new SMTP session for the incoming connection.
|
// NewSession creates a new SMTP session for the incoming connection.
|
||||||
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
||||||
return &smtpSession{
|
return &smtpSession{
|
||||||
backend: be,
|
backend: be,
|
||||||
rcpts: make([]string, 0),
|
mode: be.mode,
|
||||||
attachments: make([]*db.Attachment, 0),
|
rcpts: make([]string, 0),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// smtpSession implements the smtp.Session interface for handling a single connection.
|
// smtpSession implements the smtp.Session interface for handling a single connection.
|
||||||
type smtpSession struct {
|
type smtpSession struct {
|
||||||
backend *smtpBackend
|
backend *smtpBackend
|
||||||
from string
|
mode smtpMode
|
||||||
rcpts []string
|
from string
|
||||||
|
rcpts []string
|
||||||
authenticated bool
|
authenticated bool
|
||||||
username string
|
userID uint
|
||||||
attachments []*db.Attachment
|
email string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthPlain authenticates the user with plain-text credentials.
|
// AuthMechanisms returns supported SMTP AUTH mechanisms.
|
||||||
func (s *smtpSession) AuthPlain(username, password string) error {
|
func (s *smtpSession) AuthMechanisms() []string {
|
||||||
user, err := s.backend.server.stores.Users.Authenticate(username, password)
|
return []string{sasl.Plain}
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("authentication failed: %w", err)
|
|
||||||
|
// 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
|
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||||
s.username = user.Username
|
user, err := s.backend.server.stores.Users.Authenticate(username, password)
|
||||||
return nil
|
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).
|
// Mail records the sender address (MAIL FROM command).
|
||||||
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
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.from = from
|
||||||
s.rcpts = s.rcpts[:0]
|
s.rcpts = s.rcpts[:0]
|
||||||
s.attachments = s.attachments[:0]
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rcpt validates and records a recipient address (RCPT TO command).
|
// 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 {
|
func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
||||||
parts := strings.SplitN(to, "@", 2)
|
if _, err := s.localUserByEmail(to); err != nil {
|
||||||
if len(parts) != 2 {
|
if s.authenticated {
|
||||||
return fmt.Errorf("invalid recipient address: %s", to)
|
return fmt.Errorf("external relay is not supported yet: %s", to)
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("relay access denied: %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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.rcpts = append(s.rcpts, to)
|
s.rcpts = append(s.rcpts, to)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data handles the message body (DATA command). It parses the MIME message,
|
func (s *smtpSession) localUserByEmail(email string) (*db.User, error) {
|
||||||
// extracts fields and attachments, and stores the message for each recipient.
|
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 {
|
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)
|
data, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read message data: %w", err)
|
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))
|
mr, err := mail.CreateReader(bytes.NewReader(data))
|
||||||
if err != nil {
|
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
|
header := mr.Header
|
||||||
|
msg := &parsedSMTPMessage{}
|
||||||
fromAddr := mailutil.FormatAddressList(&header, "From")
|
msg.fromAddr = mailutil.FormatAddressList(&header, "From")
|
||||||
toAddr := mailutil.FormatAddressList(&header, "To")
|
msg.toAddr = mailutil.FormatAddressList(&header, "To")
|
||||||
ccAddr := mailutil.FormatAddressList(&header, "Cc")
|
msg.ccAddr = mailutil.FormatAddressList(&header, "Cc")
|
||||||
subject, _ := header.Subject()
|
msg.subject, _ = header.Subject()
|
||||||
messageID, _ := header.MessageID()
|
msg.messageID, _ = header.MessageID()
|
||||||
date, _ := header.Date()
|
msg.date, _ = header.Date()
|
||||||
if date.IsZero() {
|
if msg.date.IsZero() {
|
||||||
date = time.Now()
|
msg.date = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
var textBody, htmlBody string
|
|
||||||
var attachments []*db.Attachment
|
|
||||||
|
|
||||||
// Iterate through all MIME parts
|
|
||||||
for {
|
for {
|
||||||
p, err := mr.NextPart()
|
p, err := mr.NextPart()
|
||||||
if err == io.EOF {
|
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)
|
log.Printf("SMTP: error reading inline part: %v", readErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 检测并转换字符集
|
|
||||||
charset := ""
|
charset := ""
|
||||||
if cs, ok := params["charset"]; ok {
|
if cs, ok := params["charset"]; ok {
|
||||||
charset = cs
|
charset = cs
|
||||||
}
|
}
|
||||||
decoded := mailutil.DecodeCharset(buf, charset)
|
decoded := mailutil.DecodeCharset(buf, charset)
|
||||||
if strings.HasPrefix(contentType, "text/plain") {
|
if strings.HasPrefix(contentType, "text/plain") {
|
||||||
textBody = decoded
|
msg.textBody = decoded
|
||||||
} else if strings.HasPrefix(contentType, "text/html") {
|
} else if strings.HasPrefix(contentType, "text/html") {
|
||||||
htmlBody = decoded
|
msg.htmlBody = decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
case *mail.AttachmentHeader:
|
case *mail.AttachmentHeader:
|
||||||
@@ -225,83 +299,43 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
log.Printf("SMTP: error reading attachment part: %v", readErr)
|
log.Printf("SMTP: error reading attachment part: %v", readErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
msg.attachments = append(msg.attachments, &db.Attachment{
|
||||||
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{
|
|
||||||
FileName: filename,
|
FileName: filename,
|
||||||
FilePath: relPath,
|
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
FileSize: int64(len(buf)),
|
FileSize: int64(len(buf)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: if no text body was extracted from MIME parts, use the raw data
|
if msg.textBody == "" && msg.htmlBody == "" {
|
||||||
if textBody == "" && htmlBody == "" {
|
msg.textBody = string(data)
|
||||||
textBody = string(data)
|
|
||||||
}
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create a Message record for each verified recipient
|
func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) error {
|
||||||
for _, rcpt := range s.rcpts {
|
msg := &db.Message{
|
||||||
user, err := s.backend.server.stores.Users.GetByEmail(rcpt)
|
UserID: userID,
|
||||||
if err != nil {
|
MessageID: parsed.messageID,
|
||||||
log.Printf("SMTP: recipient not found %s, skipping", rcpt)
|
Folder: folder,
|
||||||
continue
|
FromAddr: parsed.fromAddr,
|
||||||
}
|
ToAddr: parsed.toAddr,
|
||||||
|
CcAddr: parsed.ccAddr,
|
||||||
msg := &db.Message{
|
Subject: parsed.subject,
|
||||||
UserID: user.ID,
|
TextBody: parsed.textBody,
|
||||||
MessageID: messageID,
|
HtmlBody: parsed.htmlBody,
|
||||||
Folder: "INBOX",
|
RawData: string(data),
|
||||||
FromAddr: fromAddr,
|
IsRead: read,
|
||||||
ToAddr: toAddr,
|
IsFlagged: false,
|
||||||
CcAddr: ccAddr,
|
Date: parsed.date,
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
return s.backend.server.stores.Mails.Create(msg)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset clears the session state for the next message on the same connection.
|
// Reset clears the session state for the next message on the same connection.
|
||||||
func (s *smtpSession) Reset() {
|
func (s *smtpSession) Reset() {
|
||||||
s.from = ""
|
s.from = ""
|
||||||
s.rcpts = s.rcpts[:0]
|
s.rcpts = s.rcpts[:0]
|
||||||
s.attachments = s.attachments[:0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout is called when the SMTP connection is closed.
|
// Logout is called when the SMTP connection is closed.
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ type MailStore interface {
|
|||||||
ListByUserAndFolder(userID uint, folder string, page, size int) ([]db.Message, int64, error)
|
ListByUserAndFolder(userID uint, folder string, page, size int) ([]db.Message, int64, error)
|
||||||
ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error)
|
ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error)
|
||||||
CountByUserAndFolder(userID uint, folder string) (int64, error)
|
CountByUserAndFolder(userID uint, folder string) (int64, error)
|
||||||
|
MaxIDByUserAndFolder(userID uint, folder string) (uint, error)
|
||||||
MarkRead(id uint) error
|
MarkRead(id uint) error
|
||||||
|
MarkReadState(id uint, read bool) error
|
||||||
MarkFlagged(id uint, flagged bool) error
|
MarkFlagged(id uint, flagged bool) error
|
||||||
MoveToFolder(id uint, folder string) error
|
MoveToFolder(id uint, folder string) error
|
||||||
Delete(id uint) 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.
|
// MarkRead sets the IsRead flag to true for a message.
|
||||||
func (s *mailStoreGorm) MarkRead(id uint) error {
|
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.
|
// MarkFlagged sets the IsFlagged flag for a message.
|
||||||
@@ -132,6 +139,18 @@ func (s *mailStoreGorm) CountByUserAndFolder(userID uint, folder string) (int64,
|
|||||||
return count, nil
|
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.
|
// CountByFolder returns the total count of messages in a given folder.
|
||||||
func (s *mailStoreGorm) CountByFolder(folder string) (int64, error) {
|
func (s *mailStoreGorm) CountByFolder(folder string) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/smtp"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -225,11 +224,61 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
sb.WriteString(body)
|
sb.WriteString(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send via local SMTP
|
allRecipients := append(parseAddressInput(to), parseAddressInput(cc)...)
|
||||||
err := smtp.SendMail("localhost:25", nil, fromAddr, strings.Split(to, ","), []byte(sb.String()))
|
localUsers := make([]*db.User, 0, len(allRecipients))
|
||||||
if err != nil {
|
var unsupported []string
|
||||||
// Log the error but still save to sent folder — SMTP may not be running yet
|
for _, rcpt := range allRecipients {
|
||||||
fmt.Printf("SMTP发送失败(邮件仍保存到发件箱): %v\n", err)
|
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
|
// Save to Sent folder
|
||||||
@@ -243,6 +292,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
Subject: subject,
|
Subject: subject,
|
||||||
TextBody: body,
|
TextBody: body,
|
||||||
HtmlBody: htmlBody,
|
HtmlBody: htmlBody,
|
||||||
|
RawData: sb.String(),
|
||||||
Date: now,
|
Date: now,
|
||||||
IsRead: true,
|
IsRead: true,
|
||||||
}
|
}
|
||||||
@@ -306,6 +356,20 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/sent")
|
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.
|
// mimeTypes maps common file extensions to MIME types.
|
||||||
var mimeTypes = map[string]string{
|
var mimeTypes = map[string]string{
|
||||||
".txt": "text/plain",
|
".txt": "text/plain",
|
||||||
|
|||||||
@@ -177,13 +177,18 @@ func main() {
|
|||||||
log.Printf("SMTP 服务启动失败: %v", err)
|
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 != "" {
|
if cfg.SMTP.TLSCert != "" && cfg.SMTP.TLSKey != "" {
|
||||||
go func() {
|
go func() {
|
||||||
if err := smtpSrv.StartTLS(); err != nil {
|
if err := smtpSrv.StartTLS(); err != nil {
|
||||||
log.Printf("SMTPS 服务启动失败: %v", err)
|
log.Printf("SMTPS 服务启动失败: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
go func() {
|
||||||
|
if err := smtpSrv.StartSubmission(); err != nil {
|
||||||
|
log.Printf("SMTP Submission 服务启动失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Start IMAP server
|
// 7. Start IMAP server
|
||||||
|
|||||||
@@ -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。
|
||||||
Reference in New Issue
Block a user