Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions internal/cmd/gmail_mime.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"mime"
"mime/quotedprintable"
"net/mail"
"net/url"
"os"
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand All @@ -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) {
Expand Down
82 changes: 82 additions & 0 deletions internal/cmd/gmail_mime_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"io"
"mime/quotedprintable"
"regexp"
"strings"
"testing"
Expand Down Expand Up @@ -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: "<p>Hello world</p>",
}, 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 <a@b.com>, Bob <b@c.com>"})
parts := strings.SplitN(got, ", ", 2)
Expand Down