diff --git a/email/email.go b/email/email.go new file mode 100644 index 0000000..f596678 --- /dev/null +++ b/email/email.go @@ -0,0 +1,379 @@ +// 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. +// +// 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 +// intentionally not here — services that need it keep their own client. +package email + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "log/slog" + "mime" + "mime/quotedprintable" + "net" + "net/mail" + "net/smtp" + "strings" + "time" +) + +// Message is a rendered mail payload a sender delivers. HTMLBody is optional +// (a text-only message is fine); when both are set the SMTP sender emits a +// multipart/alternative body. +type Message struct { + From mail.Address + To []mail.Address + Subject string + TextBody string + HTMLBody string + // Headers holds optional extra RFC 5322 headers (e.g. List-Unsubscribe). + // Reserved headers (From/To/Subject/Date/MIME-*) are skipped, and CR/LF + // injection is rejected. + Headers map[string]string +} + +// Sender delivers an already-rendered mail and exposes its default sender +// address (used when the caller has no From of its own). +type Sender interface { + Send(ctx context.Context, msg Message) error + DefaultFrom() mail.Address +} + +// LogSender logs each rendered message via slog instead of delivering it — +// the dev default for compose stacks without SMTP credentials, so mail flows +// (forgot-password, invites) can be exercised end-to-end while the content +// shows up in the service logs. It never sends. +type LogSender struct { + logger *slog.Logger + from mail.Address +} + +// NewLogSender builds a sender that prints mail to logger. +func NewLogSender(logger *slog.Logger, from mail.Address) *LogSender { + return &LogSender{logger: logger, from: from} +} + +// DefaultFrom returns the configured sender address. +func (s *LogSender) DefaultFrom() mail.Address { return s.from } + +// Send logs the message (recipients + subject only — never the body). +func (s *LogSender) Send(_ context.Context, msg Message) error { + if s.logger == nil { + return nil + } + attrs := []any{ + "from", msg.From.Address, + "recipients", len(msg.To), + "subject", msg.Subject, + } + if len(msg.To) == 1 { + attrs = append(attrs, "to", msg.To[0].Address) + } + s.logger.Info("email (log sender — not delivered)", attrs...) + return nil +} + +// SMTPConfig configures the SMTP mail transport. +// +// Transport selection: SSL=true uses implicit TLS from the first byte +// (typical port 465); SSL=false connects in plain text and upgrades via +// STARTTLS when the server offers it, unless DisableSTARTTLS is set for +// relays that have no TLS at all (e.g. an internal k8s relay). +type SMTPConfig struct { + Host string + Port int + Username string + Password string + SSL bool // implicit TLS connection (typically port 465) + // DisableSTARTTLS skips the opportunistic STARTTLS upgrade on plain + // connections so unencrypted internal relays keep working. + DisableSTARTTLS bool + DefaultFrom mail.Address + Timeout time.Duration +} + +type smtpSender struct { + host string + addr string + ssl bool + noStartTLS bool + username string + password string + defaultFrom mail.Address + timeout time.Duration +} + +// loginAuth implements the SASL LOGIN mechanism — many internal relays only +// advertise LOGIN (and Go's net/smtp only ships PLAIN/CRAM-MD5). +type loginAuth struct { + username, password string +} + +func (a *loginAuth) Start(*smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", nil, nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if !more { + return nil, nil + } + switch strings.ToLower(strings.TrimRight(string(fromServer), ":")) { + case "username": + return []byte(a.username), nil + case "password": + return []byte(a.password), nil + } + return nil, fmt.Errorf("smtp sender: unexpected LOGIN challenge %q", fromServer) +} + +// plainAuth implements SASL PLAIN without net/smtp's cleartext refusal. +// PlainAuth refuses to send credentials over an unencrypted connection to a +// non-local host, but internal k8s relays (port 25, STARTTLS disabled) often +// advertise only PLAIN — the operator explicitly opts into cleartext auth for +// those (see SMTPConfig.DisableSTARTTLS). +type plainAuth struct { + username, password string +} + +func (a *plainAuth) Start(*smtp.ServerInfo) (string, []byte, error) { + resp := []byte("\x00" + a.username + "\x00" + a.password) + return "PLAIN", resp, nil +} + +func (a *plainAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + return nil, fmt.Errorf("smtp sender: unexpected server challenge during PLAIN auth") + } + return nil, nil +} + +// NewSMTP returns an SMTP-backed sender supporting implicit-TLS, STARTTLS +// and plain transports with PLAIN or LOGIN authentication. +func NewSMTP(cfg SMTPConfig) (Sender, error) { + if strings.TrimSpace(cfg.Host) == "" { + return nil, fmt.Errorf("smtp sender: missing host") + } + if cfg.Port <= 0 { + return nil, fmt.Errorf("smtp sender: invalid port %d", cfg.Port) + } + if cfg.Timeout <= 0 { + cfg.Timeout = 10 * time.Second + } + if strings.TrimSpace(cfg.DefaultFrom.Address) == "" { + return nil, fmt.Errorf("smtp sender: missing default from address") + } + return &smtpSender{ + host: cfg.Host, + addr: net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)), + ssl: cfg.SSL, + noStartTLS: cfg.DisableSTARTTLS, + username: strings.TrimSpace(cfg.Username), + password: cfg.Password, + defaultFrom: cfg.DefaultFrom, + timeout: cfg.Timeout, + }, nil +} + +// DefaultFrom returns the configured sender address. +func (s *smtpSender) DefaultFrom() mail.Address { return s.defaultFrom } + +// authenticate negotiates AUTH with the best mechanism both sides support: +// PLAIN on encrypted (or localhost) connections, LOGIN otherwise — net/smtp's +// PlainAuth refuses to send credentials over an unencrypted connection to a +// non-local host, so plain internal relays need LOGIN. +func (s *smtpSender) authenticate(client *smtp.Client) error { + ok, mechs := client.Extension("AUTH") + if !ok { + return fmt.Errorf("smtp sender: server does not support auth") + } + advertised := make(map[string]bool) + for _, mech := range strings.Fields(mechs) { + advertised[strings.ToUpper(mech)] = true + } + _, encrypted := client.TLSConnectionState() + encrypted = encrypted || s.ssl || s.host == "localhost" || s.host == "127.0.0.1" + var auth smtp.Auth + switch { + case advertised["PLAIN"] && encrypted: + auth = smtp.PlainAuth("", s.username, s.password, s.host) + case advertised["LOGIN"]: + auth = &loginAuth{username: s.username, password: s.password} + case advertised["PLAIN"]: + // Unencrypted PLAIN-only relay: stdlib PlainAuth would refuse to send + // credentials in the clear, so use the cleartext-capable implementation. + auth = &plainAuth{username: s.username, password: s.password} + default: + return fmt.Errorf("smtp sender: server does not support a known auth mechanism (advertised: %s)", mechs) + } + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp sender: auth (%T): %w", auth, err) + } + return nil +} + +// Send delivers msg over SMTP. The envelope From/To are the address parts of +// msg.From/msg.To (display names stay in the headers). +func (s *smtpSender) Send(ctx context.Context, msg Message) error { + if strings.TrimSpace(msg.From.Address) == "" { + return fmt.Errorf("smtp sender: missing from address") + } + if len(msg.To) == 0 { + return fmt.Errorf("smtp sender: missing recipients") + } + if strings.TrimSpace(msg.Subject) == "" { + return fmt.Errorf("smtp sender: missing subject") + } + body, err := buildMessage(msg) + if err != nil { + return err + } + + var conn net.Conn + if s.ssl { + d := &net.Dialer{Timeout: s.timeout} + conn, err = tls.DialWithDialer(d, "tcp", s.addr, &tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}) + } else { + conn, err = (&net.Dialer{Timeout: s.timeout}).DialContext(ctx, "tcp", s.addr) + } + if err != nil { + return fmt.Errorf("smtp sender: dial %s: %w", s.addr, err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(s.timeout)) + + client, err := smtp.NewClient(conn, s.host) + if err != nil { + return fmt.Errorf("smtp sender: create client: %w", err) + } + defer client.Close() + + if !s.ssl && !s.noStartTLS { + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil { + return fmt.Errorf("smtp sender: starttls: %w", err) + } + } + } + if s.username != "" { + if err := s.authenticate(client); err != nil { + return err + } + } + if err := client.Mail(msg.From.Address); err != nil { + return fmt.Errorf("smtp sender: mail from %s: %w", msg.From.Address, err) + } + for _, rcpt := range msg.To { + if err := client.Rcpt(rcpt.Address); err != nil { + return fmt.Errorf("smtp sender: rcpt to %s: %w", rcpt.Address, err) + } + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp sender: data: %w", err) + } + if _, err := w.Write(body); err != nil { + _ = w.Close() + return fmt.Errorf("smtp sender: write body: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp sender: close body: %w", err) + } + if err := client.Quit(); err != nil { + return fmt.Errorf("smtp sender: quit: %w", err) + } + return nil +} + +// buildMessage renders the RFC 5322 wire message: headers (From/To/Subject +// Q-encoded/Date/custom), then a multipart/alternative body when both text +// and HTML are present, else a single quoted-printable part. +func buildMessage(msg Message) ([]byte, error) { + if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" { + return nil, fmt.Errorf("email: missing body") + } + + var buf bytes.Buffer + writeHeader := func(name, value string) { + buf.WriteString(name) + buf.WriteString(": ") + buf.WriteString(value) + buf.WriteString("\r\n") + } + writeHeader("From", msg.From.String()) + toList := make([]string, 0, len(msg.To)) + for _, rcpt := range msg.To { + toList = append(toList, rcpt.String()) + } + writeHeader("To", strings.Join(toList, ", ")) + writeHeader("Subject", mime.QEncoding.Encode("utf-8", msg.Subject)) + writeHeader("Date", time.Now().Format(time.RFC1123Z)) + // Custom headers — reserved headers are skipped to avoid clobbering the + // ones written above; CR/LF injection is rejected. + for name, value := range msg.Headers { + switch strings.ToLower(strings.TrimSpace(name)) { + case "from", "to", "subject", "date", "mime-version", "content-type", "content-transfer-encoding": + continue + } + if strings.ContainsAny(name, "\r\n") || strings.ContainsAny(value, "\r\n") { + continue + } + writeHeader(name, value) + } + writeHeader("MIME-Version", "1.0") + + switch { + case msg.TextBody != "" && msg.HTMLBody != "": + boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano()) + writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, boundary)) + buf.WriteString("\r\n") + writeMIMEPart(&buf, boundary, "text/plain; charset=utf-8", msg.TextBody) + writeMIMEPart(&buf, boundary, "text/html; charset=utf-8", msg.HTMLBody) + buf.WriteString("--") + buf.WriteString(boundary) + buf.WriteString("--\r\n") + case msg.HTMLBody != "": + writeHeader("Content-Type", `text/html; charset=utf-8`) + writeHeader("Content-Transfer-Encoding", "quoted-printable") + buf.WriteString("\r\n") + if err := writeQuotedPrintable(&buf, msg.HTMLBody); err != nil { + return nil, fmt.Errorf("email: encode html body: %w", err) + } + default: + writeHeader("Content-Type", `text/plain; charset=utf-8`) + writeHeader("Content-Transfer-Encoding", "quoted-printable") + buf.WriteString("\r\n") + if err := writeQuotedPrintable(&buf, msg.TextBody); err != nil { + return nil, fmt.Errorf("email: encode text body: %w", err) + } + } + return buf.Bytes(), nil +} + +func writeMIMEPart(buf *bytes.Buffer, boundary, contentType, body string) { + buf.WriteString("--") + buf.WriteString(boundary) + buf.WriteString("\r\n") + buf.WriteString("Content-Type: ") + buf.WriteString(contentType) + buf.WriteString("\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + _ = writeQuotedPrintable(buf, body) + buf.WriteString("\r\n") +} + +func writeQuotedPrintable(buf *bytes.Buffer, body string) error { + w := quotedprintable.NewWriter(buf) + if _, err := w.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n"))); err != nil { + return err + } + return w.Close() +} diff --git a/email/email_test.go b/email/email_test.go new file mode 100644 index 0000000..b7d2c60 --- /dev/null +++ b/email/email_test.go @@ -0,0 +1,178 @@ +package email + +import ( + "context" + "net/mail" + "strings" + "testing" + "time" +) + +func testFrom() mail.Address { + return mail.Address{Address: "no-reply@example.com"} +} + +func TestLoginAuthChallengeResponse(t *testing.T) { + a := &loginAuth{username: "user", password: "pass"} + mech, initial, err := a.Start(nil) + if err != nil { + t.Fatalf("Start: %v", err) + } + if mech != "LOGIN" || initial != nil { + t.Fatalf("unexpected Start result: %q %v", mech, initial) + } + resp, err := a.Next([]byte("Username:"), true) + if err != nil || string(resp) != "user" { + t.Fatalf("username challenge: %q %v", resp, err) + } + resp, err = a.Next([]byte("Password"), true) + if err != nil || string(resp) != "pass" { + t.Fatalf("password challenge: %q %v", resp, err) + } + if _, err := a.Next([]byte("Bogus"), true); err == nil { + t.Fatal("expected error on unexpected challenge") + } +} + +func TestPlainAuthSendsCredentialsInClear(t *testing.T) { + // Internal relays advertise only PLAIN over an unencrypted connection; + // stdlib smtp.PlainAuth refuses that (ErrUnencryptedConnection), so the + // sender uses the cleartext-capable plainAuth instead. + a := &plainAuth{username: "user", password: "pass"} + mech, initial, err := a.Start(nil) + if err != nil { + t.Fatalf("Start: %v", err) + } + if mech != "PLAIN" { + t.Fatalf("mechanism = %q, want PLAIN", mech) + } + want := "\x00user\x00pass" + if string(initial) != want { + t.Fatalf("initial response = %q, want %q", initial, want) + } + if resp, err := a.Next(nil, false); err != nil || resp != nil { + t.Fatalf("Next(done) = %q %v, want nil nil", resp, err) + } + if _, err := a.Next(nil, true); err == nil { + t.Fatal("expected error on unexpected challenge") + } +} + +func TestNewSMTPValidation(t *testing.T) { + if _, err := NewSMTP(SMTPConfig{Port: 587}); err == nil || + !strings.Contains(err.Error(), "missing host") { + t.Fatalf("want missing-host error, got %v", err) + } + if _, err := NewSMTP(SMTPConfig{Host: "relay.internal", Port: 0, DefaultFrom: testFrom()}); err == nil || + !strings.Contains(err.Error(), "port") { + t.Fatalf("want invalid-port error, got %v", err) + } + s, err := NewSMTP(SMTPConfig{ + Host: "relay.internal", + Port: 25, + SSL: false, + DefaultFrom: testFrom(), + Timeout: time.Second, + }) + if err != nil { + t.Fatalf("NewSMTP: %v", err) + } + smtpS, ok := s.(*smtpSender) + if !ok { + t.Fatalf("unexpected sender type %T", s) + } + if smtpS.ssl || smtpS.addr != "relay.internal:25" { + t.Fatalf("unexpected config: %+v", smtpS) + } +} + +func TestBuildMessageMultipartAlternative(t *testing.T) { + msg := Message{ + From: mail.Address{Name: "go-auth", Address: "no-reply@example.com"}, + To: []mail.Address{{Name: "Ada", Address: "ada@example.com"}}, + Subject: "Reset your password — Åke & Östen", + TextBody: "Hello Ada,\nreset link: https://auth.example/reset?token=abc\n", + HTMLBody: "

Hello Ada,

reset link: https://auth.example/reset?token=abc

", + Headers: map[string]string{"List-Unsubscribe": ""}, + } + raw, err := buildMessage(msg) + if err != nil { + t.Fatal(err) + } + out := string(raw) + + for _, want := range []string{ + "From: \"go-auth\" ", + "To: \"Ada\" ", + "Subject: =?utf-8?q?", + "MIME-Version: 1.0", + "multipart/alternative;", + "Content-Type: text/plain; charset=utf-8", + "Content-Type: text/html; charset=utf-8", + "List-Unsubscribe: ", + "Hello Ada,", + } { + if !strings.Contains(out, want) { + t.Errorf("message missing %q\n%s", want, out) + } + } + // The multipart must terminate properly. + if !strings.Contains(out, "--alt-") || !strings.HasSuffix(out, "--\r\n") { + t.Errorf("multipart envelope not closed properly:\n%s", out) + } +} + +func TestBuildMessageTextOnly(t *testing.T) { + raw, err := buildMessage(Message{ + From: testFrom(), + To: []mail.Address{{Address: "ada@example.com"}}, + Subject: "hi", + TextBody: "plain only", + }) + if err != nil { + t.Fatal(err) + } + out := string(raw) + if strings.Contains(out, "multipart") { + t.Errorf("text-only message must not be multipart:\n%s", out) + } + if !strings.Contains(out, "Content-Type: text/plain; charset=utf-8") { + t.Errorf("missing text/plain content type:\n%s", out) + } +} + +func TestBuildMessageRejectsHeaderInjection(t *testing.T) { + raw, err := buildMessage(Message{ + From: testFrom(), + To: []mail.Address{{Address: "ada@example.com"}}, + Subject: "hi", + TextBody: "body", + Headers: map[string]string{"X-Evil": "a\r\nBcc: victim@example.com"}, + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "Bcc: victim@example.com") { + t.Error("CR/LF injection survived into the message") + } +} + +func TestBuildMessageMissingBody(t *testing.T) { + if _, err := buildMessage(Message{ + From: testFrom(), To: []mail.Address{{Address: "a@b"}}, Subject: "x", + }); err == nil { + t.Fatal("want missing-body error") + } +} + +func TestLogSenderNeverSends(t *testing.T) { + s := NewLogSender(nil, testFrom()) + if err := s.Send(context.Background(), Message{ + From: testFrom(), To: []mail.Address{{Address: "a@b"}}, Subject: "x", TextBody: "y", + }); err != nil { + t.Fatalf("log sender: %v", err) + } + if s.DefaultFrom().Address != "no-reply@example.com" { + t.Fatalf("DefaultFrom = %q", s.DefaultFrom().Address) + } +}