platform/email: add attachment support; order delegates SMTP to it

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 <noreply@codebuff.com>
This commit is contained in:
mats committed 2026-09-01 20:04:46 +02:00
1 parent 59c737b4fb
commit b585245d47
2 files changed
+115 -4

No files matched your search

+70 -4
View File
@@ -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)