updated layout for webmail and added http dns letsencrypt

This commit is contained in:
2026-08-15 12:35:44 +01:00
parent 310700407e
commit f283c90f11
49 changed files with 3359 additions and 431 deletions
+101
View File
@@ -0,0 +1,101 @@
package smtpserver
import (
"net/smtp"
"testing"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
// createTestMailboxWithQuota mirrors newTestBackendWithMailbox's inline mailbox setup,
// parameterized by quota so this file can create both a normal and an
// effectively-always-full mailbox.
func createTestMailboxWithQuota(t *testing.T, backend *Backend, store *mailstore.Store, email string, quotaBytes int64) int64 {
t.Helper()
dek := mailstore.GenerateDEK()
wrapped, nonce, err := store.WrapDEK(dek)
if err != nil {
t.Fatal(err)
}
hash, err := db.HashPassword("portal-password-unused")
if err != nil {
t.Fatal(err)
}
mailboxID, err := backend.DB.CreateMailbox(email, hash, 1, quotaBytes, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
return mailboxID
}
// TestPartialLocalDeliveryFailureBouncesAndAccepts confirms a multi-recipient
// transaction where one local mailbox accepts the message and another can't (quota
// exceeded, discovered only during DATA — RCPT can't catch it) is accepted (250, not
// 550 — the successful recipient already has it, so the client must not retry the
// whole transaction) and that the sender gets a bounce in their own mailbox describing
// the recipient that failed.
func TestPartialLocalDeliveryFailureBouncesAndAccepts(t *testing.T) {
backend, okMailboxID := newTestBackendWithMailbox(t)
store := backend.Mailstore
fullMailboxID := createTestMailboxWithQuota(t, backend, store, "full@example.com", 1)
senderMailboxID := createTestMailboxWithQuota(t, backend, store, "test@example.com", 5*1024*1024*1024)
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)
}
if err := c.Mail("test@example.com"); err != nil {
t.Fatalf("MAIL FROM: %v", err)
}
if err := c.Rcpt("inbox@example.com"); err != nil {
t.Fatalf("RCPT (ok mailbox): %v", err)
}
if err := c.Rcpt("full@example.com"); err != nil {
t.Fatalf("RCPT (over-quota mailbox, still accepted at RCPT time): %v", err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("Subject: hello\r\n\r\nhi there")); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatalf("expected DATA to succeed (250, partial success) despite one recipient failing, got: %v", err)
}
okMsgs, err := backend.DB.ListMessagesInFolder(okMailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(okMsgs) != 1 {
t.Fatalf("expected the message delivered to inbox@example.com, got %d messages", len(okMsgs))
}
fullMsgs, err := backend.DB.ListMessagesInFolder(fullMailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(fullMsgs) != 0 {
t.Fatalf("expected no message delivered to the over-quota mailbox, got %d", len(fullMsgs))
}
bounces, err := backend.DB.ListMessagesInFolder(senderMailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(bounces) != 1 {
t.Fatalf("expected 1 bounce message in the sender's own mailbox, got %d", len(bounces))
}
if bounces[0].CachedSubject != "Undelivered Mail Returned to Sender" {
t.Errorf("bounce subject = %q", bounces[0].CachedSubject)
}
}
+86
View File
@@ -0,0 +1,86 @@
package smtpserver
import "testing"
// TestEmailLogBodyOmittedByDefault confirms the admin-visible email log gets the
// message headers but never the body by default — only Subject/headers are diagnostic
// metadata; the body is content, which shouldn't sit in a log unless explicitly opted
// into (store_message_content) or the message needed spam review (see
// TestEmailLogBodyKeptWhenQuarantined).
func TestEmailLogBodyOmittedByDefault(t *testing.T) {
backend, _ := newTestBackendWithMailbox(t) // spam_reject_score set sky-high, so nothing quarantines here
addr := startTestServer(t, backend)
if err := sendTestMessage(t, addr, "hello"); err != nil {
t.Fatalf("send: %v", err)
}
logs, err := backend.DB.ListEmailLogsPage(0, 10)
if err != nil {
t.Fatal(err)
}
if len(logs) != 1 {
t.Fatalf("expected 1 email log entry, got %d", len(logs))
}
if logs[0].MessageBody != "" {
t.Errorf("expected no body logged by default, got %q", logs[0].MessageBody)
}
if logs[0].EmailHeaders == "" {
t.Error("expected headers to still be logged even with body omitted")
}
}
// TestEmailLogBodyKeptWhenStoreMessageContentEnabled confirms the sender's own
// "Store Full Message Content" opt-in (esrv_senders.store_message_content) still works
// despite the new default-off body logging.
func TestEmailLogBodyKeptWhenStoreMessageContentEnabled(t *testing.T) {
backend, _ := newTestBackendWithMailbox(t)
if _, err := backend.DB.Exec(`UPDATE esrv_senders SET store_message_content = 1 WHERE email = 'test@example.com'`); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
if err := sendTestMessage(t, addr, "hello"); err != nil {
t.Fatalf("send: %v", err)
}
logs, err := backend.DB.ListEmailLogsPage(0, 10)
if err != nil {
t.Fatal(err)
}
if len(logs) != 1 || logs[0].MessageBody == "" {
t.Fatalf("expected the opted-in sender's message body to be logged, got %+v", logs)
}
}
// TestEmailLogBodyKeptWhenQuarantined confirms a message quarantined to Spam still
// gets its body logged even without any opt-in, so an admin can actually review a
// spam/abuse report — the one deliberate exception to the default-off rule.
func TestEmailLogBodyKeptWhenQuarantined(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
rspamd := fakeRspamd(t, 20, "add header")
backend.Cfg.Section("Rspamd").Key("enabled").SetValue("true")
backend.Cfg.Section("Rspamd").Key("url").SetValue(rspamd.URL)
backend.Cfg.Section("Rspamd").Key("reject_score").SetValue("15")
addr := startTestServer(t, backend)
if err := sendTestMessage(t, addr, "hello"); err != nil {
t.Fatalf("send: %v", err)
}
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
if err != nil {
t.Fatal(err)
}
if len(spamMsgs) != 1 {
t.Fatalf("expected the message quarantined to Spam, got %d Spam messages", len(spamMsgs))
}
logs, err := backend.DB.ListEmailLogsPage(0, 10)
if err != nil {
t.Fatal(err)
}
if len(logs) != 1 || logs[0].MessageBody == "" {
t.Fatalf("expected the quarantined message's body to be logged for review, got %+v", logs)
}
}
@@ -18,6 +18,7 @@ func newTestBackendWithMailbox(t *testing.T) (*Backend, int64) {
store := mailstore.New(backend.DB, mailstore.GenerateDEK(), t.TempDir())
backend.Mailstore = store
backend.Relay.Mailstore = store // mirrors main.go's wiring, needed for SendBounce's local-delivery shortcut
// Spam/SPF/DNSBL checks make live DNS calls (see internal/mailstore) — deliberately
// so in production, but that makes their exact score environment-dependent (e.g. a
// resolver that hijacks NXDOMAIN, or a real SPF record on the test domain). These
@@ -80,6 +81,52 @@ func TestLocalDeliveryToKnownMailbox(t *testing.T) {
}
}
// TestLocalDeliveryCachesFromHeaderDisplayName confirms cached_from is the message's
// own From: header (e.g. "Bob Marley <bob@example.com>"), not the bare SMTP envelope
// address — the envelope rarely carries a display name, but the header usually does,
// and webmail's folder list wants the display name to show.
func TestLocalDeliveryCachesFromHeaderDisplayName(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(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)
}
if err := c.Mail("test@example.com"); err != nil {
t.Fatalf("MAIL FROM: %v", err)
}
if err := c.Rcpt("inbox@example.com"); err != nil {
t.Fatalf("RCPT: %v", err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("From: Bob Marley <test@example.com>\r\nSubject: hello\r\n\r\nhi there")); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatalf("DATA: %v", err)
}
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].CachedFrom != "Bob Marley <test@example.com>" {
t.Errorf("cached_from = %q, want the From: header value with display name", msgs[0].CachedFrom)
}
}
func TestLocalDeliveryUnknownMailboxRejected(t *testing.T) {
backend, _ := newTestBackendWithMailbox(t)
addr := startTestServer(t, backend)
+66 -9
View File
@@ -248,6 +248,14 @@ func (s *Session) Data(r io.Reader) error {
toHeader := rebuiltHeaders["to"]
ccHeader := rebuiltHeaders["cc"]
subject := rebuiltHeaders["subject"]
// The message's own From: header (e.g. "Bob Marley <bob@example.com>"), not the
// bare SMTP envelope address — used only for what's cached/displayed (webmail's
// folder list), never for delivery/auth decisions, which stay on s.mailFrom
// throughout. Falls back to the envelope address if the header's missing/empty.
fromHeader := rebuiltHeaders["from"]
if fromHeader == "" {
fromHeader = s.mailFrom
}
// Attachment storage: only if the authenticated sender or whitelisted IP opted in.
storeMessage := false
@@ -321,23 +329,66 @@ func (s *Session) Data(r io.Reader) error {
results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes)
}
if len(localRcpts) > 0 {
results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject)...)
results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject, fromHeader)...)
}
allSucceeded := len(results) > 0
var failed []relay.Result
for _, res := range results {
if res.Status != "success" {
allSucceeded = false
failed = append(failed, res)
}
}
allSucceeded := len(results) > 0 && len(failed) == 0
anySucceeded := len(results) > len(failed)
// A single SMTP response to DATA can't express "delivered to some recipients, not
// others" — rejecting the whole transaction here would make the connecting
// server's own retry logic re-deliver to the recipients that already succeeded.
// So: accept (below) and bounce the failed subset back to our own sender instead,
// exactly like a real MTA splitting a multi-recipient transaction's outcome. A
// bounce is never sent for a *total* failure — that gets rejected outright (550)
// below instead, letting the connecting server's own MTA generate the bounce to
// its user, avoiding a double notification. Skipped entirely for a null-sender
// message (s.mailFrom == "", already itself a bounce/DSN — replying to one is the
// classic bounce-loop bug) and for a currently-blacklisted peer, so a delivery
// failure never becomes a free "yes, that mailbox doesn't exist" oracle for abuse.
if len(failed) > 0 && anySucceeded && s.mailFrom != "" {
if blacklisted, _ := s.backend.DB.IsIPBlacklisted(s.peerIP); !blacklisted {
if err := s.backend.Relay.SendBounce(s.mailFrom, subject, messageID, failed); err != nil {
s.backend.Logger.Error("send bounce to %s: %v", s.mailFrom, err)
}
}
}
var emailHeaders, messageBody string
var emailHeaders 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)
// Privacy default: only headers (and the Subject field, logged separately below
// regardless) go into the admin-visible log, never the message itself — unless this
// sender/IP explicitly opted in via "Store Full Message Content" (storeMessage
// above), or the message was quarantined to Spam for at least one recipient, in
// which case an admin genuinely needs to see it to judge a spam/abuse report. When
// stored, it's the *entire* raw message (not a plain-text extraction) so the log
// viewer can render the real HTML body, inline images, and attachments — re-parsed
// on demand via internal/mailview, the same parser webmail's own message view uses
// — rather than a degraded text-only approximation.
storeContent := storeMessage
if !storeContent {
for _, res := range results {
if res.Quarantined {
storeContent = true
break
}
}
}
loggedBody := ""
if storeContent {
loggedBody = signedContent
}
logID, logErr := s.backend.Relay.LogEmail(s.backend.Cfg, s.peerIP, s.mailFrom, toHeader, ccHeader, "", subject, emailHeaders, loggedBody, messageID, s.username, dkimSigned, results)
if logErr != nil {
s.backend.Logger.Error("Failed to log email: %v", logErr)
} else {
@@ -353,6 +404,12 @@ func (s *Session) Data(r io.Reader) error {
if allSucceeded {
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery"}
}
if anySucceeded {
// Some recipients already have the message — 250 it (see the bounce comment
// above for why), not 550, which would tell the connecting server to retry
// the whole thing and re-deliver to those recipients a second time.
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery to some recipients"}
}
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"}
}
@@ -361,7 +418,7 @@ func (s *Session) Data(r io.Reader) error {
// and stores it into each resolved local mailbox, producing one relay.Result per
// recipient so it can be merged into the same LogEmail/allSucceeded logic as relay
// results.
func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID, subject string) []relay.Result {
func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID, subject, fromDisplay string) []relay.Result {
senderDomain := domainOfAddr(s.mailFrom)
dkimPass := senderDomain != "" && dkim.VerifyInbound(signedContent, senderDomain)
spfPass := mailstore.CheckSPF(s.mailFrom, s.peerIP)
@@ -430,7 +487,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
markRead = action.MarkRead
}
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, s.mailFrom, subject)
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, fromDisplay, subject)
if err != nil {
errCode, errMsg := "450", err.Error()
if err == mailstore.ErrQuotaExceeded {
@@ -448,7 +505,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
if spamGated {
serverResponse = "Quarantined to Spam folder"
}
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse})
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse, Quarantined: spamGated})
}
return results
}