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:
1 parent
59c737b4fb
commit
b585245d47
2 files changed
+115
-4
No files matched your search
+70
-4
@@ -1,10 +1,10 @@
|
|||||||
// Package email is the platform's shared transactional-mail transport: a
|
// Package email is the platform's shared transactional-mail transport: a
|
||||||
// small Sender interface with an SMTP implementation (implicit TLS, STARTTLS
|
// small Sender interface with an SMTP implementation (implicit TLS, STARTTLS
|
||||||
// and plain relays; PLAIN/LOGIN auth) and a dev-only log sender. It was
|
// 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
|
// extracted from go-cart-actor's pkg/order — whose SMTPEmailSender now
|
||||||
// newsletter paths use it) so a second consumer — go-auth's forgot-password
|
// delegates here (order, ledger and newsletter mail flow through it) — and
|
||||||
// and invite mail — reuses the same transport contract instead of
|
// go-auth's forgot-password / invite mail is the second consumer, so every
|
||||||
// duplicating it.
|
// service shares one SMTP implementation.
|
||||||
//
|
//
|
||||||
// Pure stdlib (net/smtp, crypto/tls, mime, net/mail): no external SDK, so it
|
// 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
|
// stays usable from the stdlib-only services. The MailerSend API sender is
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime"
|
"mime"
|
||||||
@@ -39,6 +40,16 @@ type Message struct {
|
|||||||
// Reserved headers (From/To/Subject/Date/MIME-*) are skipped, and CR/LF
|
// Reserved headers (From/To/Subject/Date/MIME-*) are skipped, and CR/LF
|
||||||
// injection is rejected.
|
// injection is rejected.
|
||||||
Headers map[string]string
|
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
|
// 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")
|
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 {
|
switch {
|
||||||
case msg.TextBody != "" && msg.HTMLBody != "":
|
case msg.TextBody != "" && msg.HTMLBody != "":
|
||||||
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
|
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
|
||||||
@@ -358,6 +385,45 @@ func buildMessage(msg Message) ([]byte, error) {
|
|||||||
return buf.Bytes(), nil
|
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) {
|
func writeMIMEPart(buf *bytes.Buffer, boundary, contentType, body string) {
|
||||||
buf.WriteString("--")
|
buf.WriteString("--")
|
||||||
buf.WriteString(boundary)
|
buf.WriteString(boundary)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package email
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestBuildMessageMissingBody(t *testing.T) {
|
||||||
if _, err := buildMessage(Message{
|
if _, err := buildMessage(Message{
|
||||||
From: testFrom(), To: []mail.Address{{Address: "a@b"}}, Subject: "x",
|
From: testFrom(), To: []mail.Address{{Address: "a@b"}}, Subject: "x",
|
||||||
|
|||||||
Reference in new issue
Block a user