From 264273626e69bdfea1a8d60ab19a312683b8ad84 Mon Sep 17 00:00:00 2001 From: resolvicomai Date: Thu, 21 May 2026 06:10:18 -0300 Subject: [PATCH] fix(sender): handle qp errors (#1318) ## What? - Add a shared quoted-printable writer helper that returns write and close errors. - Use it for plaintext-only and multipart text/html message parts. - Add regression coverage for a failing quoted-printable flush. Closes #614 ## Why? Ignoring `quotedprintable.Writer.Close()` can let email composition continue with an incomplete or corrupted encoded body. --- sender/sender.go | 29 ++++++++++++++++++++--------- sender/sender_test.go | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/sender/sender.go b/sender/sender.go index 4df43a934bf16f968391b83fc8bf863157ce5617..816b3bdecc156e4c7c1b2e0c0d69d252aea44eaf 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -184,6 +184,17 @@ func detectPlaintextOnly(body string, images, attachments map[string][]byte) boo return !containsMarkup(body) } +func writeQuotedPrintable(w io.Writer, body string) error { + qp := quotedprintable.NewWriter(w) + if _, err := fmt.Fprint(qp, body); err != nil { + return fmt.Errorf("quoted-printable encoding failed: %w", err) + } + if err := qp.Close(); err != nil { + return fmt.Errorf("quoted-printable encoding failed: %w", err) + } + return nil +} + // SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments. func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool, signPGP bool, encryptPGP bool) ([]byte, error) { smtpServer := account.GetSMTPServer() @@ -245,9 +256,9 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody // Build quoted-printable encoded body var encBody bytes.Buffer - qp := quotedprintable.NewWriter(&encBody) - fmt.Fprint(qp, plainBody) - qp.Close() + if err := writeQuotedPrintable(&encBody, plainBody); err != nil { + return nil, err + } encodedBody := encBody.Bytes() // Build the canonical MIME part (headers + body) used for signing/encryption @@ -381,9 +392,9 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody if err != nil { return nil, err } - qpText := quotedprintable.NewWriter(textPart) - fmt.Fprint(qpText, plainBody) - qpText.Close() + if err := writeQuotedPrintable(textPart, plainBody); err != nil { + return nil, err + } // HTML part htmlHeader := textproto.MIMEHeader{ @@ -394,9 +405,9 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody if err != nil { return nil, err } - qpHTML := quotedprintable.NewWriter(htmlPart) - fmt.Fprint(qpHTML, htmlBody) - qpHTML.Close() + if err := writeQuotedPrintable(htmlPart, htmlBody); err != nil { + return nil, err + } altWriter.Close() // Finish the alternative part diff --git a/sender/sender_test.go b/sender/sender_test.go index d3fc7542fb91e1d89f603fa9b1df8d48de890ffc..d6a0efb8db48baca5d323d489bd502495fb8aefe 100644 --- a/sender/sender_test.go +++ b/sender/sender_test.go @@ -13,6 +13,22 @@ func (failingReader) Read(p []byte) (int, error) { return 0, errors.New("simulated crypto/rand failure") } +type failingWriter struct{} + +func (failingWriter) Write(p []byte) (int, error) { + return 0, errors.New("simulated write failure") +} + +func TestWriteQuotedPrintablePropagatesFlushError(t *testing.T) { + err := writeQuotedPrintable(failingWriter{}, "hello") + if err == nil { + t.Fatal("expected quoted-printable write error, got nil") + } + if !strings.Contains(err.Error(), "quoted-printable encoding failed") { + t.Fatalf("expected quoted-printable context, got %v", err) + } +} + // TestSMIMEOuterBoundary_RandFailure ensures that a crypto/rand failure surfaces // as an error rather than silently producing a predictable, time-based // boundary that an attacker could collide with (issue #1127).