first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user