Author SHA1 Message Date
dsh 1b81e5ccf8 feat: 出站地址族可配置(ip_family=ipv4/ipv6/auto)+ 源地址绑定(source_ip)
- ip_family 默认 ipv4(保持 PTR/SPF 最可靠的路径);运营商为静态 IPv6
  配置 PTR 后可切换 ipv6
- source_ip 绑定出站源地址,避免内核使用轮换的 IPv6 临时隐私地址
  (临时地址无 PTR,Gmail 等会拒收)
- 无 MX 回退时按地址族偏好排序 A/AAAA
2026-08-15 23:45:44 -04:00
dsh 56a3baae95 fix: relay_starttls 默认值始终为 true(未显式配置时),避免中继密码明文传输 2026-08-15 23:41:01 -04:00
dsh 75a1619c68 fix: 外发优先走 IPv4(Gmail 拒收无 PTR 的 IPv6);新增 smarthost 中继支持
- 出站 SMTP 连接强制 IPv4(tcp4),无 MX 回退时 IPv4 优先:
  住宅/动态 IP 的 IPv6 临时地址通常无 PTR,Gmail 会以 5.7.25 拒收,
  而 IPv4 一般具备正反向一致的 PTR(实测 Gmail 250 OK)
- [outbound] 新增 relay_host/relay_port/relay_user/relay_password/
  relay_starttls:配置后所有外部投递经智能主机中继(AUTH PLAIN、
  465 隐式 TLS / 其他端口 STARTTLS),解决服务器 IP 被 Spamhaus PBL
  收录时 Outlook/Hotmail 拒收的问题
- 新增 smarthost 中继单元测试
2026-08-15 23:39:57 -04:00
2 changed files with 6 additions and 153 deletions
+6 -38
View File
@@ -285,14 +285,7 @@ type parsedSMTPMessage struct {
textBody string
htmlBody string
date time.Time
attachments []*parsedAttachment
}
// parsedAttachment holds an extracted MIME attachment part.
type parsedAttachment struct {
fileName string
contentType string
data []byte
attachments []*db.Attachment
}
func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) {
@@ -353,10 +346,10 @@ func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) {
log.Printf("SMTP: error reading attachment part: %v", readErr)
continue
}
msg.attachments = append(msg.attachments, &parsedAttachment{
fileName: filename,
contentType: contentType,
data: buf,
msg.attachments = append(msg.attachments, &db.Attachment{
FileName: filename,
ContentType: contentType,
FileSize: int64(len(buf)),
})
}
}
@@ -383,32 +376,7 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP
IsFlagged: false,
Date: parsed.date,
}
if err := s.backend.server.stores.Mails.Create(msg); err != nil {
return err
}
// Persist attachments to disk and link them to the message so that the
// Web mail UI can list/download them and quota accounting stays correct.
for _, att := range parsed.attachments {
relPath, err := s.backend.server.storage.Save(att.fileName, att.data)
if err != nil {
log.Printf("SMTP: failed to save attachment %s: %v", att.fileName, err)
continue
}
rec := &db.Attachment{
MessageID: msg.ID,
FileName: att.fileName,
FilePath: relPath,
ContentType: att.contentType,
FileSize: int64(len(att.data)),
}
if err := s.backend.server.stores.Attachments.Create(rec); err != nil {
log.Printf("SMTP: failed to create attachment record: %v", err)
continue
}
_ = s.backend.server.stores.Users.UpdateUsedBytes(userID, rec.FileSize)
}
return nil
return s.backend.server.stores.Mails.Create(msg)
}
// Reset clears the session state for the next message on the same connection.
-115
View File
@@ -1,115 +0,0 @@
package smtp_server
import (
"bytes"
"fmt"
"testing"
"mail_go/internal/db"
"mail_go/internal/storage"
"mail_go/internal/store"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// testMultipartMessage builds an RFC 5322 message with one text part and one
// base64 attachment.
func testMultipartMessage() []byte {
const boundary = "X"
return []byte(fmt.Sprintf(
"From: sender@example.com\r\n"+
"To: rcpt@lmve.net\r\n"+
"Subject: with attachment\r\n"+
"MIME-Version: 1.0\r\n"+
"Content-Type: multipart/mixed; boundary=\"%s\"\r\n"+
"\r\n"+
"--%s\r\n"+
"Content-Type: text/plain; charset=utf-8\r\n"+
"\r\n"+
"hello body\r\n"+
"--%s\r\n"+
"Content-Type: text/plain; name=\"test.txt\"\r\n"+
"Content-Transfer-Encoding: base64\r\n"+
"Content-Disposition: attachment; filename=\"test.txt\"\r\n"+
"\r\n"+
"aGVsbG8gd29ybGQ=\r\n"+
"--%s--\r\n",
boundary, boundary, boundary, boundary))
}
func TestParseSMTPMessageExtractsAttachmentData(t *testing.T) {
parsed, err := parseSMTPMessage(testMultipartMessage())
if err != nil {
t.Fatalf("parseSMTPMessage: %v", err)
}
if parsed.textBody != "hello body" {
t.Fatalf("unexpected text body: %q", parsed.textBody)
}
if len(parsed.attachments) != 1 {
t.Fatalf("expected 1 attachment, got %d", len(parsed.attachments))
}
att := parsed.attachments[0]
if att.fileName != "test.txt" {
t.Fatalf("unexpected filename: %q", att.fileName)
}
if string(att.data) != "hello world" {
t.Fatalf("unexpected attachment data: %q", att.data)
}
}
func TestSaveMessagePersistsAttachments(t *testing.T) {
gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
attStorage := storage.NewAttachmentStorage(t.TempDir())
srv := &SMTPServer{stores: stores, storage: attStorage}
sess := &smtpSession{backend: &smtpBackend{server: srv}}
data := testMultipartMessage()
parsed, err := parseSMTPMessage(data)
if err != nil {
t.Fatalf("parseSMTPMessage: %v", err)
}
user := &db.User{Username: "rcpt", PasswordHash: "x", DomainID: 0, IsActive: true}
if err := stores.Users.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
if err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
t.Fatalf("saveMessage: %v", err)
}
msgs, err := stores.Mails.ListAllByUserAndFolder(user.ID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 inbox message, got %d (err=%v)", len(msgs), err)
}
atts, err := stores.Attachments.ListByMessage(msgs[0].ID)
if err != nil {
t.Fatalf("ListByMessage: %v", err)
}
if len(atts) != 1 {
t.Fatalf("expected 1 attachment record, got %d", len(atts))
}
att := atts[0]
if att.FileName != "test.txt" || att.FileSize != int64(len("hello world")) {
t.Fatalf("unexpected attachment record: %+v", att)
}
// The file must exist on disk with the original content.
content, err := attStorage.Read(att.FilePath)
if err != nil {
t.Fatalf("read attachment from disk: %v", err)
}
if !bytes.Equal(content, []byte("hello world")) {
t.Fatalf("attachment content mismatch: %q", content)
}
}