From b585245d4784a99063e27942aece3d96e3987c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 1 Sep 2026 20:04:46 +0200 Subject: [PATCH] platform/email: add attachment support; order delegates SMTP to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-cart-actor's hand-rolled SMTP stack (SASL loginAuth/plainAuth, dial/ STARTTLS/auth/MAIL/RCPT/DATA wire code, MIME builders) is replaced by an adapter over platform/email, which gains the multipart/mixed attachment envelope ported from it. One SMTP implementation now serves both the order/ledger senders and go-auth's AUTH_EMAIL_* flows; public surfaces (SMTPEmailSenderConfig, NewSMTPEmailSender, EmailSender, EmailMessage) are unchanged so all callers compile untouched. Bumps the go-cart-actor pin to 7cad997. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- email/email.go | 74 ++++++++++++++++++++++++++++++++++++++++++--- email/email_test.go | 45 +++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/email/email.go b/email/email.go index f596678..ef1d86a 100644 --- a/email/email.go +++ b/email/email.go @@ -1,10 +1,10 @@ // Package email is the platform's shared transactional-mail transport: a // small Sender interface with an SMTP implementation (implicit TLS, STARTTLS // and plain relays; PLAIN/LOGIN auth) and a dev-only log sender. It was -// extracted from go-cart-actor's pkg/order (where the order, ledger and -// newsletter paths use it) so a second consumer — go-auth's forgot-password -// and invite mail — reuses the same transport contract instead of -// duplicating it. +// extracted from go-cart-actor's pkg/order — whose SMTPEmailSender now +// delegates here (order, ledger and newsletter mail flow through it) — and +// go-auth's forgot-password / invite mail is the second consumer, so every +// service shares one SMTP implementation. // // Pure stdlib (net/smtp, crypto/tls, mime, net/mail): no external SDK, so it // stays usable from the stdlib-only services. The MailerSend API sender is @@ -15,6 +15,7 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" "fmt" "log/slog" "mime" @@ -39,6 +40,16 @@ type Message struct { // Reserved headers (From/To/Subject/Date/MIME-*) are skipped, and CR/LF // injection is rejected. Headers map[string]string + // Attachments holds binary payloads (e.g. an invoice PDF). The SMTP + // sender wraps the body in a multipart/mixed envelope with one base64 + // part per attachment. + Attachments []Attachment +} + +// Attachment is one binary payload attached to a message. +type Attachment struct { + Filename string + Content []byte } // Sender delivers an already-rendered mail and exposes its default sender @@ -330,6 +341,22 @@ func buildMessage(msg Message) ([]byte, error) { } writeHeader("MIME-Version", "1.0") + if len(msg.Attachments) > 0 { + // Top level becomes multipart/mixed wrapping the alternative body and + // each attachment part. + mixed := fmt.Sprintf("mixed-%d", time.Now().UnixNano()) + writeHeader("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mixed)) + buf.WriteString("\r\n") + writeBodyPart(&buf, mixed, msg) + for _, a := range msg.Attachments { + writeAttachmentPart(&buf, mixed, a) + } + buf.WriteString("--") + buf.WriteString(mixed) + buf.WriteString("--\r\n") + return buf.Bytes(), nil + } + switch { case msg.TextBody != "" && msg.HTMLBody != "": boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano()) @@ -358,6 +385,45 @@ func buildMessage(msg Message) ([]byte, error) { return buf.Bytes(), nil } +// writeBodyPart writes the text/html alternative body as one part inside a +// multipart/mixed envelope. +func writeBodyPart(buf *bytes.Buffer, boundary string, msg Message) { + alt := fmt.Sprintf("alt-%d", time.Now().UnixNano()) + buf.WriteString("--") + buf.WriteString(boundary) + buf.WriteString("\r\n") + buf.WriteString("Content-Type: ") + buf.WriteString(fmt.Sprintf(`multipart/alternative; boundary="%s"`, alt)) + buf.WriteString("\r\n\r\n") + if msg.TextBody != "" { + writeMIMEPart(buf, alt, "text/plain; charset=utf-8", msg.TextBody) + } + if msg.HTMLBody != "" { + writeMIMEPart(buf, alt, "text/html; charset=utf-8", msg.HTMLBody) + } + buf.WriteString("--") + buf.WriteString(alt) + buf.WriteString("--\r\n") +} + +// writeAttachmentPart writes one base64 attachment part. +func writeAttachmentPart(buf *bytes.Buffer, boundary string, a Attachment) { + buf.WriteString("--") + buf.WriteString(boundary) + buf.WriteString("\r\n") + buf.WriteString("Content-Type: application/pdf; name=\"") + buf.WriteString(a.Filename) + buf.WriteString("\"\r\n") + buf.WriteString("Content-Transfer-Encoding: base64\r\n") + buf.WriteString("Content-Disposition: attachment; filename=\"") + buf.WriteString(a.Filename) + buf.WriteString("\"\r\n\r\n") + w := base64.NewEncoder(base64.StdEncoding, buf) + _, _ = w.Write(a.Content) + _ = w.Close() + buf.WriteString("\r\n") +} + func writeMIMEPart(buf *bytes.Buffer, boundary, contentType, body string) { buf.WriteString("--") buf.WriteString(boundary) diff --git a/email/email_test.go b/email/email_test.go index b7d2c60..55f8465 100644 --- a/email/email_test.go +++ b/email/email_test.go @@ -2,6 +2,7 @@ package email import ( "context" + "encoding/base64" "net/mail" "strings" "testing" @@ -157,6 +158,50 @@ func TestBuildMessageRejectsHeaderInjection(t *testing.T) { } } +func TestBuildMessageAttachment(t *testing.T) { + body, err := buildMessage(Message{ + From: mail.Address{Name: "B2B Demo AB", Address: "faktura@demo.se"}, + To: []mail.Address{{Address: "faktura@falk.se"}}, + Subject: "Faktura INV-000001", + TextBody: "Hej,\nFaktura bifogas.", + Attachments: []Attachment{{ + Filename: "INV-000001.pdf", + Content: []byte("%PDF-1.4 fake"), + }}, + }) + if err != nil { + t.Fatalf("buildMessage: %v", err) + } + s := string(body) + for _, want := range []string{ + "multipart/mixed;", + `Content-Type: application/pdf; name="INV-000001.pdf"`, + `Content-Disposition: attachment; filename="INV-000001.pdf"`, + "Content-Transfer-Encoding: base64", + } { + if !strings.Contains(s, want) { + t.Errorf("message missing %q", want) + } + } + + // The attachment payload round-trips: extract the base64 blob between the + // attachment part header and the closing CRLF, decode, compare. + idx := strings.Index(s, `Content-Disposition: attachment; filename="INV-000001.pdf"`) + rest := s[idx:] + lineEnd := strings.Index(rest, "\r\n\r\n") + if lineEnd < 0 { + t.Fatal("attachment part has no body separator") + } + b64line := strings.SplitN(rest[lineEnd+4:], "\r\n", 2)[0] + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64line)) + if err != nil { + t.Fatalf("decode attachment base64: %v", err) + } + if string(decoded) != "%PDF-1.4 fake" { + t.Fatalf("attachment content = %q", decoded) + } +} + func TestBuildMessageMissingBody(t *testing.T) { if _, err := buildMessage(Message{ From: testFrom(), To: []mail.Address{{Address: "a@b"}}, Subject: "x",