diff --git a/internal/cmd/gmail_mime.go b/internal/cmd/gmail_mime.go index 73c6ef81..7c521415 100644 --- a/internal/cmd/gmail_mime.go +++ b/internal/cmd/gmail_mime.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "mime" + "mime/quotedprintable" "net/mail" "net/url" "os" @@ -142,9 +143,9 @@ func buildRFC822(opts mailOptions, cfg *rfc822Config) ([]byte, error) { return b.Bytes(), nil default: writeHeader(&b, "Content-Type", "text/plain; charset=\"utf-8\"") - writeHeader(&b, "Content-Transfer-Encoding", "7bit") + writeHeader(&b, "Content-Transfer-Encoding", "quoted-printable") b.WriteString("\r\n") - writeBodyWithTrailingCRLF(&b, plainBody) + writeQuotedPrintableBody(&b, plainBody) return b.Bytes(), nil } } @@ -175,8 +176,8 @@ func buildRFC822(opts mailOptions, cfg *rfc822Config) ([]byte, error) { writeBodyWithTrailingCRLF(&b, htmlBody) default: b.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") - b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n") - writeBodyWithTrailingCRLF(&b, plainBody) + b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + writeQuotedPrintableBody(&b, plainBody) } // Attachments @@ -282,6 +283,16 @@ func wrapBase64(b []byte) string { return out.String() } +func writeQuotedPrintableBody(b *bytes.Buffer, body string) { + qpw := quotedprintable.NewWriter(b) + _, _ = qpw.Write([]byte(body)) + _ = qpw.Close() + // Ensure trailing CRLF after the encoded body. + if !bytes.HasSuffix(b.Bytes(), []byte("\r\n")) { + b.WriteString("\r\n") + } +} + func writeBodyWithTrailingCRLF(b *bytes.Buffer, body string) { b.WriteString(body) if !strings.HasSuffix(body, "\r\n") { @@ -292,8 +303,13 @@ func writeBodyWithTrailingCRLF(b *bytes.Buffer, body string) { func writeTextPart(b *bytes.Buffer, boundary string, contentType string, body string) { _, _ = fmt.Fprintf(b, "--%s\r\n", boundary) _, _ = fmt.Fprintf(b, "Content-Type: %s\r\n", contentType) - b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n") - writeBodyWithTrailingCRLF(b, body) + if strings.HasPrefix(contentType, "text/plain") { + b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + writeQuotedPrintableBody(b, body) + } else { + b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n") + writeBodyWithTrailingCRLF(b, body) + } } func randomBoundary() (string, error) { diff --git a/internal/cmd/gmail_mime_test.go b/internal/cmd/gmail_mime_test.go index bb5d3fe9..f8def2d8 100644 --- a/internal/cmd/gmail_mime_test.go +++ b/internal/cmd/gmail_mime_test.go @@ -1,6 +1,8 @@ package cmd import ( + "io" + "mime/quotedprintable" "regexp" "strings" "testing" @@ -342,6 +344,86 @@ func TestFormatAddressHeadersFiltersEmpty(t *testing.T) { } } +func TestBuildRFC822PlainBodyNotHardWrapped(t *testing.T) { + // A single long paragraph (~200 chars) must survive round-trip through + // quoted-printable encoding without hard line breaks in the decoded output. + longLine := "Hope you are doing well. I wanted to connect you both as I believe there could be a mutually interesting conversation around potential synergies between your respective companies and their product offerings." + raw, err := buildRFC822(mailOptions{ + From: "a@b.com", + To: []string{"c@d.com"}, + Subject: "Test", + Body: longLine, + }, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + s := string(raw) + + // Must use quoted-printable, not 7bit, to avoid transport-level wrapping. + if !strings.Contains(s, "Content-Transfer-Encoding: quoted-printable") { + t.Fatalf("expected quoted-printable encoding, got: %q", s) + } + + // Decode the QP body and verify the original line is intact. + // Split at the header/body separator. + parts := strings.SplitN(s, "\r\n\r\n", 2) + if len(parts) != 2 { + t.Fatalf("could not find header/body separator in: %q", s) + } + bodyEncoded := parts[1] + decoded, err := io.ReadAll(quotedprintable.NewReader(strings.NewReader(bodyEncoded))) + if err != nil { + t.Fatalf("QP decode error: %v", err) + } + decodedStr := strings.TrimRight(string(decoded), "\r\n") + if decodedStr != longLine { + t.Fatalf("decoded body mismatch:\n got: %q\n want: %q", decodedStr, longLine) + } +} + +func TestBuildRFC822PlainBodyMultiParagraph(t *testing.T) { + body := "First long paragraph that should flow naturally without any hard wrapping at seventy-two characters or any other artificial limit.\r\n\r\nSecond paragraph also long enough to verify it stays as one logical line when decoded from quoted-printable.\r\n\r\nThird short paragraph." + raw, err := buildRFC822(mailOptions{ + From: "a@b.com", + To: []string{"c@d.com"}, + Subject: "Test", + Body: body, + }, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + s := string(raw) + + parts := strings.SplitN(s, "\r\n\r\n", 2) + if len(parts) != 2 { + t.Fatalf("could not find header/body separator") + } + decoded, err := io.ReadAll(quotedprintable.NewReader(strings.NewReader(parts[1]))) + if err != nil { + t.Fatalf("QP decode error: %v", err) + } + decodedStr := strings.TrimRight(string(decoded), "\r\n") + if decodedStr != body { + t.Fatalf("decoded body mismatch:\n got: %q\n want: %q", decodedStr, body) + } +} + +func TestBuildRFC822HTMLBodyStays7bit(t *testing.T) { + raw, err := buildRFC822(mailOptions{ + From: "a@b.com", + To: []string{"c@d.com"}, + Subject: "Test", + BodyHTML: "
Hello world
", + }, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + s := string(raw) + if !strings.Contains(s, "Content-Transfer-Encoding: 7bit") { + t.Fatalf("expected 7bit encoding for HTML body, got: %q", s) + } +} + func TestFormatAddressHeadersParsesCommaSeparatedList(t *testing.T) { got := formatAddressHeaders([]string{"Alice , Bob "}) parts := strings.SplitN(got, ", ", 2)