updated mailbox app password
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user