first commit

This commit is contained in:
2026-08-12 12:56:22 +01:00
commit ff96be2708
153 changed files with 27779 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
package smtpserver
import (
"bytes"
"io"
"mime"
"mime/multipart"
"net/mail"
"path/filepath"
"strings"
"time"
)
// attachmentStoragePath mirrors smtp_handler.get_attachment_storage_path:
// {base}/{safe_domain}/{username_or_ip}/{YYYY-DD-MMM}/
func attachmentStoragePath(base, domain, usernameOrIP string, now time.Time) string {
safeDomain := sanitizePathSegment(domain, "/\\")
dateFolder := now.Format("2006-02-Jan")
parts := []string{base, safeDomain}
if usernameOrIP != "" {
parts = append(parts, usernameOrIP)
}
parts = append(parts, dateFolder)
return filepath.Join(parts...)
}
func sanitizePathSegment(s string, chars string) string {
for _, c := range chars {
s = strings.ReplaceAll(s, string(c), "_")
}
return s
}
// cleanMessageIDPrefix strips everything from "@" onward, mirroring the
// clean_message_id computation used to build attachment filenames.
func cleanMessageIDPrefix(messageID string) string {
if i := strings.Index(messageID, "@"); i >= 0 {
return messageID[:i]
}
return messageID
}
type attachmentPart struct {
Filename string
ContentType string
Data []byte
}
type parsedMessage struct {
HeaderLines []string // "Name: value" per header, in order
BodyText string // concatenated text/* parts
Attachments []attachmentPart
}
// parseMessage mirrors the repeated BytesParser(policy=policy.default) passes in
// handle_DATA: it extracts header lines for logging, concatenated text body, and any
// attachment parts (Content-Disposition: attachment with a filename).
func parseMessage(raw []byte) (*parsedMessage, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, err
}
out := &parsedMessage{}
for k, vs := range msg.Header {
for _, v := range vs {
out.HeaderLines = append(out.HeaderLines, k+": "+v)
}
}
contentType := msg.Header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = "text/plain"
}
if strings.HasPrefix(mediaType, "multipart/") {
mr := multipart.NewReader(msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
data, _ := io.ReadAll(part)
disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
partCT := part.Header.Get("Content-Type")
partMediaType, _, _ := mime.ParseMediaType(partCT)
if disp == "attachment" && dispParams["filename"] != "" {
out.Attachments = append(out.Attachments, attachmentPart{
Filename: dispParams["filename"],
ContentType: getContentType(partMediaType, dispParams["filename"]),
Data: data,
})
continue
}
if strings.HasPrefix(partMediaType, "text/") && disp != "attachment" {
out.BodyText += string(data) + "\n"
}
}
} else if strings.HasPrefix(mediaType, "text/") {
data, _ := io.ReadAll(msg.Body)
out.BodyText = string(data)
}
out.BodyText = strings.TrimSpace(out.BodyText)
return out, nil
}
+101
View File
@@ -0,0 +1,101 @@
package smtpserver
import (
"fmt"
"time"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"mailgoserver/internal/db"
)
// loginServer implements the LOGIN SASL mechanism server-side (go-sasl only ships the
// client half), mirroring the state machine aiosmtpd's built-in LOGIN handler drives:
// ask for username (unless an initial response already supplied it), then password.
type loginServer struct {
state int // 0: need username, 1: need password
username string
verify func(username, password string) error
}
func (s *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
switch s.state {
case 0:
if response == nil {
return []byte("Username:"), false, nil
}
s.username = string(response)
s.state = 1
return []byte("Password:"), false, nil
case 1:
password := string(response)
if err := s.verify(s.username, password); err != nil {
return nil, false, err
}
return nil, true, nil
default:
return nil, false, fmt.Errorf("unexpected LOGIN state")
}
}
// AuthMechanisms mirrors CustomSMTP._get_auth_methods's effective mechanism set
// (aiosmtpd's default LOGIN/PLAIN) once auth is allowed at all — the TLS-required gate
// itself is handled by go-smtp's own AllowInsecureAuth/isTLS check per listener.
func (s *Session) AuthMechanisms() []string {
return []string{sasl.Login, sasl.Plain}
}
// Auth mirrors EnhancedCombinedAuthenticator.__call__ for the LOGIN/PLAIN case (the
// only mechanisms advertised): credentials are always present by the time verify runs,
// so the "no auth_data supplied" fallback branch in the Python version is unreachable
// here and isn't replicated.
func (s *Session) Auth(mech string) (sasl.Server, error) {
switch mech {
case sasl.Login:
return &loginServer{verify: s.authenticate}, nil
case sasl.Plain:
return sasl.NewPlainServer(func(identity, username, password string) error {
return s.authenticate(username, password)
}), nil
default:
return nil, smtp.ErrAuthUnknownMechanism
}
}
// authenticate mirrors EnhancedAuthenticator.__call__: verifies credentials, logs an
// AuthLog row either way, and on any failure returns a *smtp.SMTPError carrying the
// exact Python response code/message, arming the connection to close right after that
// response is flushed — mirroring CustomSMTP.smtp_AUTH's transport.close() override.
func (s *Session) authenticate(username, password string) error {
sender, err := s.backend.DB.GetSenderByEmail(username)
if err != nil {
s.backend.Logger.Error("Authentication error: %v", err)
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", err))
return s.failAuth(451, "Internal server error")
}
if sender == nil || !db.CheckPassword(password, sender.PasswordHash) {
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Invalid credentials for %s", username))
return s.failAuth(535, "Authentication failed")
}
s.authenticatedSender = sender
s.authType = "sender"
s.username = username
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, true, "Successful sender authentication")
return nil
}
// failAuth builds the SMTPError for a failed AUTH attempt and closes the connection
// shortly after go-smtp writes this response, mirroring CustomSMTP.smtp_AUTH's
// transport.close() override. go-smtp writes the response synchronously right after
// this error is returned, so a short delay comfortably outlasts that write without
// needing to intercept the raw connection (which would break TLS detection on the
// implicit-TLS listener — see server.go).
func (s *Session) failAuth(code int, message string) error {
conn := s.conn
go func() {
time.Sleep(100 * time.Millisecond)
conn.Close()
}()
return &smtp.SMTPError{Code: code, EnhancedCode: smtp.NoEnhancedCode, Message: message}
}
+207
View File
@@ -0,0 +1,207 @@
package smtpserver
import (
"fmt"
"mime"
"net/mail"
"strings"
"time"
"mailgoserver/internal/toolbox"
)
// extractMessageID scans the raw content's header block for an existing Message-ID
// header and, if its hostname doesn't match heloHostname, rewrites it to use
// heloHostname — mirroring the pre-scan in smtp_handler.handle_DATA. Unlike the Python
// version, a missing "@" or missing header entirely is handled explicitly instead of
// crashing (the approved bug fix), by falling back to a freshly generated Message-ID.
func extractMessageID(content, heloHostname string) string {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
break // end of header block
}
lower := strings.ToLower(line)
if !strings.HasPrefix(lower, "message-id:") {
continue
}
value := strings.TrimSpace(line[len("message-id:"):])
value = strings.Trim(value, "<>")
at := strings.LastIndex(value, "@")
if at < 0 {
break // malformed header, no "@" — fall through to generating a fresh one
}
prefix, hostname := value[:at], value[at+1:]
if !strings.EqualFold(hostname, heloHostname) {
return fmt.Sprintf("%s@%s", prefix, heloHostname)
}
return value
}
return toolbox.GenerateMessageID(heloHostname)
}
// existingHeaders parses the raw header block into a lowercase-keyed map of the first
// value seen per header name, folding continuation lines, mirroring the case-insensitive
// existing-header lookups in _ensure_required_headers.
func existingHeaders(content string) map[string]string {
lines := strings.Split(content, "\n")
out := map[string]string{}
var lastKey string
for _, raw := range lines {
line := strings.TrimRight(raw, "\r")
if line == "" {
break
}
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && lastKey != "" {
out[lastKey] += " " + strings.TrimSpace(line)
continue
}
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
key := strings.ToLower(strings.TrimSpace(line[:idx]))
val := strings.TrimSpace(line[idx+1:])
out[key] = val
lastKey = key
}
return out
}
// splitHeadersBody separates the header block from the body on the first blank line,
// accepting either CRLF or bare-LF line endings (source content is LF-only from the
// SMTP DATA decode; ensureRequiredHeaders' own output is CRLF).
func splitHeadersBody(content string) (headerBlock, body string) {
if idx := strings.Index(content, "\r\n\r\n"); idx >= 0 {
return content[:idx], content[idx+4:]
}
if idx := strings.Index(content, "\n\n"); idx >= 0 {
return content[:idx], content[idx+2:]
}
return content, ""
}
// ensureRequiredHeaders performs a full header-block *replacement* (not augmentation),
// mirroring smtp_handler._ensure_required_headers exactly: a fixed, ordered whitelist
// of headers is emitted, copying values from the original message where present and
// defaulting where absent; anything outside that whitelist is dropped, then the
// domain's custom headers plus X-Originating-IP/X-Mailer/X-Priority are appended (only
// if not already present under the same name).
func ensureRequiredHeaders(content, messageID string, envelopeRcptTos []string, mailFrom string, customHeaders [][2]string) string {
headerBlock, body := splitHeadersBody(content)
existing := existingHeaders(headerBlock)
var out []string
out = append(out, "Message-ID: <"+messageID+">")
if v, ok := existing["date"]; ok {
out = append(out, "Date: "+v)
} else {
out = append(out, "Date: "+time.Now().Format(time.RFC1123Z))
}
if v, ok := existing["mime-version"]; ok {
out = append(out, "MIME-Version: "+v)
} else {
out = append(out, "MIME-Version: 1.0")
}
if v, ok := existing["to"]; ok {
out = append(out, "To: "+v)
} else {
out = append(out, "To: "+strings.Join(envelopeRcptTos, ", "))
}
if v, ok := existing["cc"]; ok {
out = append(out, "Cc: "+v)
}
if v, ok := existing["from"]; ok {
out = append(out, "From: "+v)
} else {
out = append(out, "From: "+mailFrom)
}
if v, ok := existing["subject"]; ok {
out = append(out, "Subject: "+v)
} else {
out = append(out, "Subject: ")
}
if v, ok := existing["content-type"]; ok {
out = append(out, "Content-Type: "+v)
} else {
out = append(out, `Content-Type: text/plain; charset=UTF-8; format=flowed`)
}
if v, ok := existing["content-transfer-encoding"]; ok {
out = append(out, "Content-Transfer-Encoding: "+v)
} else {
out = append(out, "Content-Transfer-Encoding: 7bit")
}
for _, kv := range customHeaders {
if _, already := existing[strings.ToLower(kv[0])]; already {
continue
}
out = append(out, kv[0]+": "+kv[1])
}
return strings.Join(out, "\r\n") + "\r\n\r\n" + body
}
// getContentType mirrors smtp_handler.get_content_type: prefer the part's own type,
// fall back to extension sniffing, then a small fixed extension map.
func getContentType(partContentType, filename string) string {
if partContentType != "" && partContentType != "application/octet-stream" {
return partContentType
}
if guessed := mime.TypeByExtension(extOf(filename)); guessed != "" {
return guessed
}
switch strings.ToLower(extOf(filename)) {
case ".txt":
return "text/plain"
case ".csv":
return "text/csv"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".pdf":
return "application/pdf"
case ".json":
return "application/json"
case ".xml":
return "application/xml"
case ".html", ".htm":
return "text/html"
default:
return "application/octet-stream"
}
}
func extOf(filename string) string {
if i := strings.LastIndex(filename, "."); i >= 0 {
return filename[i:]
}
return ""
}
// parseAddressList mirrors the lowercase address parsing used to classify To/Cc/Bcc.
func parseAddressList(headerValue string) []string {
if strings.TrimSpace(headerValue) == "" {
return nil
}
addrs, err := mail.ParseAddressList(headerValue)
if err != nil {
return nil
}
out := make([]string, len(addrs))
for i, a := range addrs {
out[i] = strings.ToLower(a.Address)
}
return out
}
+67
View File
@@ -0,0 +1,67 @@
package smtpserver
import (
"strings"
"testing"
)
func TestEnsureRequiredHeadersFixedOrder(t *testing.T) {
raw := "Subject: hi\r\nX-Custom: drop-me\r\n\r\nbody text"
out := ensureRequiredHeaders(raw, "msg123@host", []string{"rcpt@example.com"}, "from@example.com", nil)
headerBlock, body := splitHeadersBody(out)
var names []string
for _, line := range strings.Split(headerBlock, "\r\n") {
if line == "" {
continue
}
names = append(names, strings.SplitN(line, ":", 2)[0])
}
want := []string{"Message-ID", "Date", "MIME-Version", "To", "From", "Subject", "Content-Type", "Content-Transfer-Encoding"}
if len(names) != len(want) {
t.Fatalf("header names = %v, want %v", names, want)
}
for i := range want {
if names[i] != want[i] {
t.Errorf("header[%d] = %q, want %q", i, names[i], want[i])
}
}
if strings.Contains(headerBlock, "X-Custom") {
t.Error("unwhitelisted header X-Custom should have been dropped, not carried through")
}
if !strings.Contains(headerBlock, "To: rcpt@example.com") {
t.Error("missing To header should be synthesized from envelope recipients")
}
if body != "body text" {
t.Errorf("body = %q, want %q", body, "body text")
}
}
func TestExtractMessageIDDoesNotCrashOnMalformedHeader(t *testing.T) {
// No "@" in the Message-ID value — the fixed bug: Python's original crashes
// here (UnboundLocalError); the Go port must fall back to a generated ID.
raw := "Message-ID: not-an-id\r\nSubject: x\r\n\r\nbody"
id := extractMessageID(raw, "mail.example.com")
if id == "" {
t.Fatal("expected a generated fallback Message-ID, got empty string")
}
if !strings.HasSuffix(id, "@mail.example.com") {
t.Errorf("fallback Message-ID = %q, want suffix @mail.example.com", id)
}
}
func TestExtractMessageIDRehostsOnHostnameMismatch(t *testing.T) {
raw := "Message-ID: <abc123@other-host.com>\r\nSubject: x\r\n\r\nbody"
id := extractMessageID(raw, "mail.example.com")
if id != "abc123@mail.example.com" {
t.Errorf("id = %q, want rehosted to mail.example.com", id)
}
}
func TestExtractMessageIDKeepsMatchingHostname(t *testing.T) {
raw := "Message-ID: <abc123@mail.example.com>\r\nSubject: x\r\n\r\nbody"
id := extractMessageID(raw, "mail.example.com")
if id != "abc123@mail.example.com" {
t.Errorf("id = %q, want unchanged", id)
}
}
+58
View File
@@ -0,0 +1,58 @@
package smtpserver
import (
"crypto/tls"
"strings"
"time"
"github.com/emersion/go-smtp"
"gopkg.in/ini.v1"
)
// ResolveBanner mirrors CustomSMTP's server_banner handling (the '""' literal-quotes
// convention for "explicitly empty"). go-smtp's greeting is always
// "220 <Domain> ESMTP Service Ready" with no hook to drop the " ESMTP Service Ready"
// suffix the way aiosmtpd's raw __ident__ override can — so when no custom banner is
// configured, this falls back to heloHostname (a normal, protocol-correct greeting)
// rather than Python's degenerate literally-empty banner. This is a disclosed, cosmetic
// interface deviation: no test tooling in this project inspects the SMTP banner text.
func ResolveBanner(cfg *ini.File, heloHostname string) string {
raw := cfg.Section("Server").Key("server_banner").String()
if raw == `""` {
raw = ""
}
raw = strings.TrimSpace(raw)
if raw == "" {
return heloHostname
}
return raw
}
// NewPlainServer mirrors server_runner.py's PlainController: no TLS context at all, so
// STARTTLS is never offered, and AUTH is advertised and usable in plaintext
// (auth_require_tls=False).
func NewPlainServer(backend *Backend, addr, banner string) *smtp.Server {
s := smtp.NewServer(backend)
s.Addr = addr
s.Domain = banner
s.AllowInsecureAuth = true
s.ReadTimeout = 5 * time.Minute
s.WriteTimeout = 5 * time.Minute
return s
}
// NewTLSServer mirrors server_runner.py's TLSController: implicit/direct TLS (like
// SMTPS on port 465) — the whole connection is encrypted from the first byte, not
// STARTTLS-negotiated. Call ListenAndServeTLS (not ListenAndServe) to run it.
func NewTLSServer(backend *Backend, addr, banner string, tlsConfig *tls.Config) *smtp.Server {
s := smtp.NewServer(backend)
s.Addr = addr
s.Domain = banner
s.TLSConfig = tlsConfig
// The session is always already TLS on this listener, so AUTH is always allowed
// either way (auth_require_tls=True in Python, which is trivially satisfied here).
s.AllowInsecureAuth = true
s.ReadTimeout = 5 * time.Minute
s.WriteTimeout = 5 * time.Minute
return s
}
+217
View File
@@ -0,0 +1,217 @@
package smtpserver
import (
"net"
"net/smtp"
"os"
"strings"
"testing"
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/relay"
"mailgoserver/internal/toolbox"
)
func newTestBackend(t *testing.T) *Backend {
t.Helper()
f, err := os.CreateTemp("", "smtp-test-*.db")
if err != nil {
t.Fatal(err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
database, err := db.Open(f.Name())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
if _, err := database.Exec(`INSERT INTO esrv_domains (domain_name, is_active, is_verified) VALUES ('example.com', 1, 1)`); err != nil {
t.Fatal(err)
}
hash, err := db.HashPassword("testpass123")
if err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`INSERT INTO esrv_senders (email, password_hash, domain_id, is_active) VALUES (?, ?, 1, 1)`, "test@example.com", hash); err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`INSERT INTO esrv_whitelisted_ips (ip_address, domain_id, is_active) VALUES ('127.0.0.1', 1, 1)`); err != nil {
t.Fatal(err)
}
cfg := ini.Empty()
logger := toolbox.GetLogger("test")
return &Backend{
DB: database,
DKIM: dkim.New(database, 1024),
Relay: relay.New(database, cfg, logger),
Cfg: cfg,
Logger: logger,
HeloHostname: "mail.example.com",
AttachmentsBasePath: t.TempDir(),
}
}
func startTestServer(t *testing.T, backend *Backend) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := NewPlainServer(backend, l.Addr().String(), "mail.example.com")
go srv.Serve(l)
t.Cleanup(func() { srv.Close() })
return l.Addr().String()
}
func TestAuthSuccessAndSenderAuthorization(t *testing.T) {
backend := newTestBackend(t)
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
t.Fatalf("expected auth success, got: %v", err)
}
if err := c.Mail("test@example.com"); err != nil {
t.Fatalf("expected MAIL FROM as own address to succeed, got: %v", err)
}
if err := c.Rcpt("someone@elsewhere.example"); err != nil {
t.Fatalf("expected RCPT to accept any address, got: %v", err)
}
}
func TestAuthFailureClosesConnection(t *testing.T) {
backend := newTestBackend(t)
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
err = c.Auth(smtp.PlainAuth("", "test@example.com", "wrongpassword", "127.0.0.1"))
if err == nil {
t.Fatal("expected auth failure")
}
if !strings.Contains(err.Error(), "535") {
t.Fatalf("expected 535 response, got: %v", err)
}
// The server should close the connection shortly after — a subsequent command
// must fail rather than succeed.
time.Sleep(300 * time.Millisecond)
if err := c.Mail("test@example.com"); err == nil {
t.Fatal("expected connection to have been closed after failed AUTH")
}
}
func TestIPWhitelistFallbackWithoutAuth(t *testing.T) {
backend := newTestBackend(t)
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
// No AUTH at all: MAIL FROM a domain whitelisted for our (loopback) peer IP.
if err := c.Mail("anyone@example.com"); err != nil {
t.Fatalf("expected IP-whitelist fallback to authorize, got: %v", err)
}
if err := c.Rcpt("rcpt@elsewhere.example"); err != nil {
t.Fatalf("expected RCPT to accept, got: %v", err)
}
}
func TestMailFromRejectedForUnauthorizedDomain(t *testing.T) {
backend := newTestBackend(t)
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
err = c.Mail("nobody@not-whitelisted.example")
if err == nil {
t.Fatal("expected MAIL FROM to be rejected for a non-whitelisted, non-authenticated domain")
}
if !strings.Contains(err.Error(), "550") {
t.Fatalf("expected 550 response, got: %v", err)
}
}
func TestUnverifiedDomainCannotSend(t *testing.T) {
backend := newTestBackend(t)
domainID, err := backend.DB.CreateDomain("unverified.example")
if err != nil {
t.Fatal(err)
}
hash, _ := db.HashPassword("testpass123")
if _, err := backend.DB.CreateSender("sender@unverified.example", hash, domainID, false, false); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.Auth(smtp.PlainAuth("", "sender@unverified.example", "testpass123", "127.0.0.1")); err != nil {
t.Fatalf("auth: %v", err)
}
err = c.Mail("sender@unverified.example")
if err == nil {
t.Fatal("expected MAIL FROM to be rejected for an unverified domain, even for an authenticated sender")
}
if !strings.Contains(err.Error(), "550") || !strings.Contains(err.Error(), "verif") {
t.Fatalf("expected a 550 mentioning verification, got: %v", err)
}
// Now verify the domain directly (bypassing DNS) and confirm sending is unblocked.
if err := backend.DB.SetDomainVerified(domainID, true); err != nil {
t.Fatal(err)
}
if err := c.Mail("sender@unverified.example"); err != nil {
t.Fatalf("expected MAIL FROM to succeed once domain is verified, got: %v", err)
}
}
func TestSenderCannotSpoofOtherAddress(t *testing.T) {
backend := newTestBackend(t)
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
t.Fatalf("auth: %v", err)
}
err = c.Mail("someoneelse@example.com")
if err == nil {
t.Fatal("expected MAIL FROM spoofing another address to be rejected (can_send_as_domain is false)")
}
if !strings.Contains(err.Error(), "550") {
t.Fatalf("expected 550 response, got: %v", err)
}
}
+276
View File
@@ -0,0 +1,276 @@
package smtpserver
import (
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"time"
"github.com/emersion/go-smtp"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/relay"
"mailgoserver/internal/toolbox"
)
// Backend holds the shared dependencies every connection's Session uses, mirroring the
// constructor args threaded through smtp_handler.EnhancedCustomSMTPHandler /
// email_server/server_runner.py.
type Backend struct {
DB *db.DB
DKIM *dkim.Manager
Relay *relay.Relay
Cfg *ini.File
Logger *toolbox.Logger
HeloHostname string
AttachmentsBasePath string
}
func (b *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
host, _, _ := net.SplitHostPort(c.Conn().RemoteAddr().String())
if host == "" {
host = c.Conn().RemoteAddr().String()
}
return &Session{backend: b, conn: c, peerIP: host}, nil
}
// Session implements smtp.Session + smtp.AuthSession for one SMTP connection, mirroring
// EnhancedCustomSMTPHandler's per-connection behavior in smtp_handler.py.
type Session struct {
backend *Backend
conn *smtp.Conn
peerIP string
authenticatedSender *db.Sender
authType string // "sender" | "ip" | ""
authorizedDomain string
username string
mailFrom string
rcptTos []string
}
func (s *Session) Reset() {
s.mailFrom = ""
s.rcptTos = nil
}
func (s *Session) Logout() error { return nil }
// Mail mirrors EnhancedCustomSMTPHandler.handle_MAIL, delegating authorization to
// validateSenderAuthorization (== auth.validate_sender_authorization).
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
ok, message := s.validateSenderAuthorization(from)
if !ok {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: message}
}
s.mailFrom = from
return nil
}
// validateSenderAuthorization mirrors auth.validate_sender_authorization exactly,
// including its two branches (already-authenticated sender vs. IP whitelist fallback)
// and the AuthLog rows each path writes.
func (s *Session) validateSenderAuthorization(mailFrom string) (bool, string) {
if mailFrom == "" {
return false, "No sender address provided"
}
fromDomain := domainOfAddr(mailFrom)
if fromDomain == "" {
return false, "Invalid sender address format"
}
// A domain must have its DNS ownership TXT record verified before it can send —
// otherwise anyone could add a domain they don't control and relay mail as it.
dom, err := s.backend.DB.GetDomainByName(fromDomain)
if err != nil {
s.backend.Logger.Error("domain lookup failed: %v", err)
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
}
if dom == nil {
return false, fmt.Sprintf("Domain %s is not configured on this server", fromDomain)
}
if !dom.IsVerified {
return false, fmt.Sprintf("Domain %s has not completed DNS ownership verification yet", fromDomain)
}
if s.authenticatedSender != nil {
sender := s.authenticatedSender
if sender.CanSendAs(mailFrom) {
return true, fmt.Sprintf("Sender authorized to send as %s", mailFrom)
}
_ = s.backend.DB.LogAuthAttempt("sender_validation", fmt.Sprintf("%s -> %s", sender.Email, mailFrom), s.peerIP, false, "")
return false, fmt.Sprintf("Sender %s not authorized to send as %s", sender.Email, mailFrom)
}
wl, err := s.backend.DB.GetWhitelistedIP(s.peerIP, fromDomain)
if err != nil {
s.backend.Logger.Error("IP authorization lookup failed: %v", err)
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
}
if wl != nil {
s.authType = "ip"
s.authorizedDomain = fromDomain
s.username = "IP:" + s.peerIP
_ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, true, fmt.Sprintf("IP %s authorized for domain %s", s.peerIP, fromDomain))
return true, fmt.Sprintf("IP authorized for domain %s", fromDomain)
}
_ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, false, fmt.Sprintf("IP %s not authorized for domain %s", s.peerIP, fromDomain))
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
}
func domainOfAddr(address string) string {
i := strings.LastIndex(address, "@")
if i < 0 {
return ""
}
return strings.ToLower(address[i+1:])
}
// Rcpt mirrors handle_RCPT: accepts any address, no validation.
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
s.rcptTos = append(s.rcptTos, to)
return nil
}
func internalError(msg string) error {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: msg}
}
// Data mirrors EnhancedCustomSMTPHandler.handle_DATA end to end: Message-ID
// extraction/rehost, full header rebuild, DKIM signing, attachment extraction/storage,
// relay delivery, and EmailLog/EmailRecipientLog/EmailAttachment persistence.
func (s *Session) Data(r io.Reader) error {
raw, err := io.ReadAll(r)
if err != nil {
return internalError("Internal server error")
}
content := string(raw)
messageID := extractMessageID(content, s.backend.HeloHostname)
senderDomain := domainOfAddr(s.mailFrom)
var customHeaders [][2]string
if senderDomain != "" {
customHeaders, _ = s.backend.DKIM.GetActiveCustomHeaders(senderDomain)
}
customHeaders = append(customHeaders,
[2]string{"X-Originating-IP", "[" + s.peerIP + "]"},
[2]string{"X-Mailer", "NetBro Mail Server 1.0"},
[2]string{"X-Priority", "3"},
)
rebuilt := ensureRequiredHeaders(content, messageID, s.rcptTos, s.mailFrom, customHeaders)
signedContent := rebuilt
dkimSigned := false
if senderDomain != "" {
signedContent = s.backend.DKIM.Sign(rebuilt, senderDomain)
dkimSigned = signedContent != rebuilt
}
rebuiltHeaders := existingHeaders(rebuilt)
toHeader := rebuiltHeaders["to"]
ccHeader := rebuiltHeaders["cc"]
subject := rebuiltHeaders["subject"]
// Attachment storage: only if the authenticated sender or whitelisted IP opted in.
storeMessage := false
if sender, _ := s.backend.DB.GetSenderByEmail(s.mailFrom); sender != nil && sender.StoreMessageContent {
storeMessage = true
} else if wl, _ := s.backend.DB.GetWhitelistedIP(s.peerIP, senderDomain); wl != nil && wl.StoreMessageContent {
storeMessage = true
}
parsed, parseErr := parseMessage(raw)
type savedAttachment struct {
Filename, ContentType, FilePath string
Size int64
}
var toSave []savedAttachment
if storeMessage && parseErr == nil && len(parsed.Attachments) > 0 {
usernameOrIP := s.username
if usernameOrIP == "" && s.peerIP != "" {
usernameOrIP = sanitizePathSegment(s.peerIP, ":")
} else {
usernameOrIP = sanitizePathSegment(usernameOrIP, "/\\")
}
storagePath := attachmentStoragePath(s.backend.AttachmentsBasePath, senderDomain, usernameOrIP, time.Now())
if err := os.MkdirAll(storagePath, 0o755); err == nil {
prefix := cleanMessageIDPrefix(messageID)
for _, a := range parsed.Attachments {
filename := prefix + "_" + a.Filename
fullPath := filepath.Join(storagePath, filename)
if err := os.WriteFile(fullPath, a.Data, 0o644); err == nil {
toSave = append(toSave, savedAttachment{Filename: a.Filename, ContentType: a.ContentType, FilePath: fullPath, Size: int64(len(a.Data))})
} else {
s.backend.Logger.Error("Failed to write attachment %s: %v", filename, err)
}
}
}
}
// Classify each envelope recipient as to/cc/bcc by presence in the To/Cc headers —
// anything not literally present in either is inferred BCC.
toList := parseAddressList(toHeader)
ccList := parseAddressList(ccHeader)
recipientTypes := make([]string, len(s.rcptTos))
for i, rcpt := range s.rcptTos {
lower := strings.ToLower(rcpt)
switch {
case containsStr(toList, lower):
recipientTypes[i] = "to"
case containsStr(ccList, lower):
recipientTypes[i] = "cc"
default:
recipientTypes[i] = "bcc"
}
}
results := s.backend.Relay.RelayEmailAsync(s.mailFrom, s.rcptTos, signedContent, recipientTypes)
allSucceeded := len(results) > 0
for _, res := range results {
if res.Status != "success" {
allSucceeded = false
}
}
var emailHeaders, messageBody string
if parseErr == nil {
emailHeaders = strings.Join(parsed.HeaderLines, "\n")
messageBody = parsed.BodyText
}
logID, logErr := s.backend.Relay.LogEmail(s.backend.Cfg, s.peerIP, s.mailFrom, toHeader, ccHeader, "", subject, emailHeaders, messageBody, messageID, s.username, dkimSigned, results)
if logErr != nil {
s.backend.Logger.Error("Failed to log email: %v", logErr)
} else {
for _, a := range toSave {
if err := s.backend.DB.InsertEmailAttachment(db.EmailAttachment{
EmailLogID: logID, Filename: a.Filename, ContentType: a.ContentType, FilePath: a.FilePath, Size: a.Size,
}); err != nil {
s.backend.Logger.Error("Failed to record attachment %s: %v", a.Filename, err)
}
}
}
if allSucceeded {
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery"}
}
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"}
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}