updated mailbox app password

This commit is contained in:
2026-08-13 07:03:40 +01:00
parent 70fa1a5f2c
commit 70c05cc777
21 changed files with 618 additions and 33 deletions
+32
View File
@@ -2,6 +2,7 @@ package smtpserver
import (
"bytes"
"encoding/base64"
"io"
"mime"
"mime/multipart"
@@ -84,6 +85,11 @@ func parseMessage(raw []byte) (*parsedMessage, error) {
break
}
data, _ := io.ReadAll(part)
// multipart.Reader auto-decodes quoted-printable transparently during Read,
// but not base64 (see the mime/multipart docs) — without this, a base64
// attachment/body part is stored/relayed-for-display as raw base64 text
// instead of its actual decoded bytes.
data = decodeContentTransferEncoding(part.Header.Get("Content-Transfer-Encoding"), data)
disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
partCT := part.Header.Get("Content-Type")
partMediaType, _, _ := mime.ParseMediaType(partCT)
@@ -107,3 +113,29 @@ func parseMessage(raw []byte) (*parsedMessage, error) {
out.BodyText = strings.TrimSpace(out.BodyText)
return out, nil
}
// decodeContentTransferEncoding decodes a MIME part's body per its
// Content-Transfer-Encoding when that isn't already handled transparently by
// multipart.Reader (which only auto-decodes quoted-printable). base64 bodies are
// wrapped at a fixed line length, so whitespace/newlines are stripped before
// decoding. Falls back to the raw bytes on a decode error or any other encoding
// (7bit/8bit/binary need no transform).
func decodeContentTransferEncoding(cte string, data []byte) []byte {
if !strings.EqualFold(strings.TrimSpace(cte), "base64") {
return data
}
cleaned := make([]byte, 0, len(data))
for _, b := range data {
switch b {
case ' ', '\t', '\r', '\n':
continue
default:
cleaned = append(cleaned, b)
}
}
decoded, err := base64.StdEncoding.DecodeString(string(cleaned))
if err != nil {
return data
}
return decoded
}
+79
View File
@@ -0,0 +1,79 @@
package smtpserver
import (
"strings"
"testing"
)
// TestParseMessageDecodesBase64Attachment guards against a bug where
// mime/multipart.Reader only auto-decodes quoted-printable (not base64), so a
// base64-encoded attachment part was stored/read back as raw base64 text instead of
// its actual decoded bytes.
func TestParseMessageDecodesBase64Attachment(t *testing.T) {
raw := "" +
"From: sender@example.com\r\n" +
"To: rcpt@example.com\r\n" +
"Subject: test\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"BOUND\"\r\n" +
"\r\n" +
"--BOUND\r\n" +
"Content-Type: text/plain\r\n" +
"\r\n" +
"hello\r\n" +
"--BOUND\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"LICENSE\"\r\n" +
"Content-Transfer-Encoding: BASE64\r\n" +
"\r\n" +
"SGVsbG8sIHdvcmxkIQ==\r\n" +
"--BOUND--\r\n"
parsed, err := parseMessage([]byte(raw))
if err != nil {
t.Fatalf("parseMessage: %v", err)
}
if len(parsed.Attachments) != 1 {
t.Fatalf("got %d attachments, want 1", len(parsed.Attachments))
}
got := string(parsed.Attachments[0].Data)
want := "Hello, world!"
if got != want {
t.Errorf("attachment data = %q, want %q (decoded from base64, not left as raw base64 text)", got, want)
}
}
// TestParseMessageLeavesNonBase64EncodingsAlone confirms 7bit/8bit/absent
// Content-Transfer-Encoding parts pass through unmodified.
func TestParseMessageLeavesNonBase64EncodingsAlone(t *testing.T) {
raw := "" +
"From: sender@example.com\r\n" +
"To: rcpt@example.com\r\n" +
"Subject: test\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"BOUND\"\r\n" +
"\r\n" +
"--BOUND\r\n" +
"Content-Type: text/plain\r\n" +
"\r\n" +
"hello\r\n" +
"--BOUND\r\n" +
"Content-Type: text/plain\r\n" +
"Content-Disposition: attachment; filename=\"notes.txt\"\r\n" +
"Content-Transfer-Encoding: 7bit\r\n" +
"\r\n" +
"plain text content\r\n" +
"--BOUND--\r\n"
parsed, err := parseMessage([]byte(raw))
if err != nil {
t.Fatalf("parseMessage: %v", err)
}
if len(parsed.Attachments) != 1 {
t.Fatalf("got %d attachments, want 1", len(parsed.Attachments))
}
got := strings.TrimSpace(string(parsed.Attachments[0].Data))
if got != "plain text content" {
t.Errorf("attachment data = %q, want %q", got, "plain text content")
}
}
+16 -3
View File
@@ -47,12 +47,13 @@ func existingHeaders(content string) map[string]string {
lines := strings.Split(content, "\n")
out := map[string]string{}
var lastKey string
trackFold := false
for _, raw := range lines {
line := strings.TrimRight(raw, "\r")
if line == "" {
break
}
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && lastKey != "" {
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && trackFold {
out[lastKey] += " " + strings.TrimSpace(line)
continue
}
@@ -62,8 +63,20 @@ func existingHeaders(content string) map[string]string {
}
key := strings.ToLower(strings.TrimSpace(line[:idx]))
val := strings.TrimSpace(line[idx+1:])
out[key] = val
lastKey = key
// Unconditional out[key]=val here would keep the *last* duplicate instead of
// the first (e.g. a client-supplied "Content-Type: text/html" sent alongside
// swaks/library-generated "Content-Type: multipart/mixed; boundary=..." for an
// attachment) — discarding the boundary and causing the raw multipart body,
// left untouched below, to be delivered under the wrong Content-Type entirely.
// mail.ReadMessage's Header.Get (used elsewhere, e.g. parseMessage) already
// takes the first occurrence of a duplicated header, so this matches that.
if _, exists := out[key]; !exists {
out[key] = val
lastKey = key
trackFold = true
} else {
trackFold = false
}
}
return out
}
+26
View File
@@ -37,6 +37,32 @@ func TestEnsureRequiredHeadersFixedOrder(t *testing.T) {
}
}
// TestEnsureRequiredHeadersKeepsFirstDuplicateContentType guards against a regression
// where a duplicated Content-Type header (e.g. swaks emitting its own
// "multipart/mixed; boundary=..." for an --attach message, followed by a
// user-supplied "--add-header Content-Type: text/html") had its *last* occurrence win
// instead of its first. Losing the multipart boundary here meant the raw multipart
// body — untouched by this rebuild — got delivered under a plain, non-multipart
// Content-Type, so the recipient's client rendered the boundary markers and base64
// attachment text as literal body content instead of a real attachment.
func TestEnsureRequiredHeadersKeepsFirstDuplicateContentType(t *testing.T) {
raw := "Subject: hi\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"BOUND\"\r\n" +
"Content-Type: text/html\r\n" +
"\r\n" +
"--BOUND\r\nContent-Type: text/plain\r\n\r\nbody\r\n--BOUND--\r\n"
out := ensureRequiredHeaders(raw, "msg123@host", []string{"rcpt@example.com"}, "from@example.com", nil)
headerBlock, _ := splitHeadersBody(out)
if !strings.Contains(headerBlock, `Content-Type: multipart/mixed; boundary="BOUND"`) {
t.Errorf("expected the first (multipart/boundary) Content-Type to win, got header block:\n%s", headerBlock)
}
if strings.Contains(headerBlock, "Content-Type: text/html") {
t.Errorf("the later duplicate Content-Type: text/html should have been dropped, got header block:\n%s", headerBlock)
}
}
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.
+3 -3
View File
@@ -61,7 +61,7 @@ func TestMailboxAppPasswordCanSendAsPrimaryButNotArbitraryAddress(t *testing.T)
if err != nil {
t.Fatal(err)
}
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash, nil); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
@@ -104,7 +104,7 @@ func TestMailboxAppPasswordCanSendAsEnabledAlias(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash, nil); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
@@ -131,7 +131,7 @@ func TestMailboxAppPasswordCannotSendAsReceiveOnlyAlias(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash, nil); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)