Implement internal mail skill MVP
This commit is contained in:
90
internal/smtp/message_test.go
Normal file
90
internal/smtp/message_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
stdsmtp "net/smtp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"internal-mail-skill/internal/config"
|
||||
)
|
||||
|
||||
func TestBuildMessageOmitsBccHeaderButKeepsRecipients(t *testing.T) {
|
||||
req := SendRequest{
|
||||
FromAddress: "agent@example.com",
|
||||
FromName: "Agent",
|
||||
To: []string{"to@example.com"},
|
||||
Cc: []string{"cc@example.com"},
|
||||
Bcc: []string{"hidden@example.com"},
|
||||
Subject: "Hello",
|
||||
Body: "Body",
|
||||
}
|
||||
|
||||
msg, recipients, err := BuildMessage(req)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildMessage() error = %v", err)
|
||||
}
|
||||
raw := string(msg)
|
||||
if strings.Contains(raw, "Bcc:") || strings.Contains(raw, "hidden@example.com") {
|
||||
t.Fatalf("raw message leaked Bcc: %s", raw)
|
||||
}
|
||||
if len(recipients) != 3 {
|
||||
t.Fatalf("recipients = %#v", recipients)
|
||||
}
|
||||
if recipients[2] != "hidden@example.com" {
|
||||
t.Fatalf("Bcc missing from envelope recipients: %#v", recipients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthForConfigRequiresExplicitOptInForCleartextAuth(t *testing.T) {
|
||||
cfg := config.SMTPConfig{
|
||||
Host: "smtp.example.local",
|
||||
Username: "user",
|
||||
Password: "secret",
|
||||
AuthRequired: true,
|
||||
}
|
||||
|
||||
if _, err := authForConfig(cfg); err == nil {
|
||||
t.Fatal("authForConfig() error = nil, want cleartext auth rejection")
|
||||
}
|
||||
|
||||
cfg.AllowInsecureAuth = true
|
||||
auth, err := authForConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("authForConfig() with opt-in error = %v", err)
|
||||
}
|
||||
proto, _, err := auth.Start(&stdsmtp.ServerInfo{Name: cfg.Host, TLS: false})
|
||||
if err != nil {
|
||||
t.Fatalf("insecure auth Start() error = %v", err)
|
||||
}
|
||||
if proto != "PLAIN" {
|
||||
t.Fatalf("proto = %q, want PLAIN", proto)
|
||||
}
|
||||
|
||||
cfg.AllowInsecureAuth = false
|
||||
cfg.UseSTARTTLS = true
|
||||
auth, err = authForConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("authForConfig() over STARTTLS error = %v", err)
|
||||
}
|
||||
if _, _, err := auth.Start(&stdsmtp.ServerInfo{Name: cfg.Host, TLS: true}); err != nil {
|
||||
t.Fatalf("TLS auth Start() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthForConfigAllowsImplicitTLSAuth(t *testing.T) {
|
||||
cfg := config.SMTPConfig{
|
||||
Host: "smtp.example.local",
|
||||
Username: "user",
|
||||
Password: "secret",
|
||||
AuthRequired: true,
|
||||
UseTLS: true,
|
||||
}
|
||||
|
||||
auth, err := authForConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("authForConfig() error = %v", err)
|
||||
}
|
||||
if _, _, err := auth.Start(&stdsmtp.ServerInfo{Name: cfg.Host, TLS: false}); err != nil {
|
||||
t.Fatalf("implicit TLS auth Start() error = %v", err)
|
||||
}
|
||||
}
|
||||
252
internal/smtp/smtp.go
Normal file
252
internal/smtp/smtp.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
stdsmtp "net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"internal-mail-skill/internal/config"
|
||||
)
|
||||
|
||||
type SendRequest struct {
|
||||
FromAddress string
|
||||
FromName string
|
||||
To []string
|
||||
Cc []string
|
||||
Bcc []string
|
||||
Subject string
|
||||
Body string
|
||||
InReplyTo string
|
||||
References string
|
||||
MessageIDHeader string
|
||||
}
|
||||
|
||||
type SendResult struct {
|
||||
MessageIDHeader string
|
||||
Raw []byte
|
||||
Recipients []string
|
||||
SentAt time.Time
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
cfg config.SMTPConfig
|
||||
}
|
||||
|
||||
func NewSender(cfg config.SMTPConfig) *Sender {
|
||||
return &Sender{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *Sender) Send(ctx context.Context, req SendRequest) (SendResult, error) {
|
||||
if req.FromAddress == "" {
|
||||
req.FromAddress = s.cfg.FromAddress
|
||||
}
|
||||
if req.FromName == "" {
|
||||
req.FromName = s.cfg.FromName
|
||||
}
|
||||
raw, recipients, err := BuildMessage(req)
|
||||
if err != nil {
|
||||
return SendResult{}, err
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
return SendResult{}, fmt.Errorf("no recipients")
|
||||
}
|
||||
if s.cfg.Host == "" {
|
||||
return SendResult{}, fmt.Errorf("SMTP_HOST is required")
|
||||
}
|
||||
auth, err := authForConfig(s.cfg)
|
||||
if err != nil {
|
||||
return SendResult{}, err
|
||||
}
|
||||
if err := s.sendRaw(ctx, req.FromAddress, recipients, raw, auth); err != nil {
|
||||
return SendResult{}, err
|
||||
}
|
||||
return SendResult{
|
||||
MessageIDHeader: extractHeader(raw, "Message-ID"),
|
||||
Raw: raw,
|
||||
Recipients: recipients,
|
||||
SentAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildMessage(req SendRequest) ([]byte, []string, error) {
|
||||
if req.FromAddress == "" {
|
||||
return nil, nil, fmt.Errorf("from address is required")
|
||||
}
|
||||
recipients := append([]string{}, req.To...)
|
||||
recipients = append(recipients, req.Cc...)
|
||||
recipients = append(recipients, req.Bcc...)
|
||||
if len(recipients) == 0 {
|
||||
return nil, nil, fmt.Errorf("at least one recipient is required")
|
||||
}
|
||||
messageID := req.MessageIDHeader
|
||||
if messageID == "" {
|
||||
messageID = newMessageID()
|
||||
}
|
||||
var b bytes.Buffer
|
||||
writeHeader(&b, "From", (&mail.Address{Name: req.FromName, Address: req.FromAddress}).String())
|
||||
writeHeader(&b, "To", strings.Join(req.To, ", "))
|
||||
if len(req.Cc) > 0 {
|
||||
writeHeader(&b, "Cc", strings.Join(req.Cc, ", "))
|
||||
}
|
||||
writeHeader(&b, "Subject", mime.QEncoding.Encode("utf-8", req.Subject))
|
||||
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
|
||||
writeHeader(&b, "Message-ID", messageID)
|
||||
if req.InReplyTo != "" {
|
||||
writeHeader(&b, "In-Reply-To", req.InReplyTo)
|
||||
}
|
||||
if req.References != "" {
|
||||
writeHeader(&b, "References", req.References)
|
||||
}
|
||||
writeHeader(&b, "MIME-Version", "1.0")
|
||||
writeHeader(&b, "Content-Type", `text/plain; charset="utf-8"`)
|
||||
writeHeader(&b, "Content-Transfer-Encoding", "8bit")
|
||||
b.WriteString("\r\n")
|
||||
body := strings.ReplaceAll(req.Body, "\n", "\r\n")
|
||||
body = strings.ReplaceAll(body, "\r\r\n", "\r\n")
|
||||
b.WriteString(body)
|
||||
if !strings.HasSuffix(body, "\r\n") {
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
return b.Bytes(), recipients, nil
|
||||
}
|
||||
|
||||
func (s *Sender) sendRaw(ctx context.Context, from string, recipients []string, raw []byte, auth stdsmtp.Auth) error {
|
||||
timeout := s.cfg.Timeout()
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
address := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port))
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
var conn net.Conn
|
||||
var err error
|
||||
if s.cfg.UseTLS {
|
||||
conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12})
|
||||
} else {
|
||||
conn, err = dialer.DialContext(ctx, "tcp", address)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect SMTP: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := stdsmtp.NewClient(conn, s.cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create SMTP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if s.cfg.UseSTARTTLS {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
}
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("smtp MAIL FROM: %w", err)
|
||||
}
|
||||
for _, recipient := range recipients {
|
||||
if strings.TrimSpace(recipient) == "" {
|
||||
continue
|
||||
}
|
||||
if err := client.Rcpt(recipient); err != nil {
|
||||
return fmt.Errorf("smtp RCPT TO %s: %w", recipient, err)
|
||||
}
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp DATA: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(writer, bytes.NewReader(raw)); err != nil {
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("write SMTP body: %w", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return fmt.Errorf("close SMTP body: %w", err)
|
||||
}
|
||||
if err := client.Quit(); err != nil {
|
||||
return fmt.Errorf("smtp quit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authForConfig(cfg config.SMTPConfig) (stdsmtp.Auth, error) {
|
||||
if !cfg.AuthRequired {
|
||||
return nil, nil
|
||||
}
|
||||
if !cfg.UseTLS && !cfg.UseSTARTTLS {
|
||||
if cfg.AllowInsecureAuth {
|
||||
return plainAuthNoTLSCheck{username: cfg.Username, password: cfg.Password}, nil
|
||||
}
|
||||
if cfg.Host != "localhost" && cfg.Host != "127.0.0.1" && cfg.Host != "::1" {
|
||||
return nil, fmt.Errorf("SMTP auth over cleartext requires SMTP_USE_TLS=true, SMTP_USE_STARTTLS=true, or SMTP_ALLOW_INSECURE_AUTH=true")
|
||||
}
|
||||
}
|
||||
if cfg.UseTLS {
|
||||
return plainAuthNoTLSCheck{username: cfg.Username, password: cfg.Password}, nil
|
||||
}
|
||||
return stdsmtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host), nil
|
||||
}
|
||||
|
||||
type plainAuthNoTLSCheck struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (a plainAuthNoTLSCheck) Start(server *stdsmtp.ServerInfo) (string, []byte, error) {
|
||||
resp := []byte("\x00" + a.username + "\x00" + a.password)
|
||||
return "PLAIN", resp, nil
|
||||
}
|
||||
|
||||
func (a plainAuthNoTLSCheck) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
return nil, fmt.Errorf("unexpected server challenge for PLAIN auth")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func writeHeader(b *bytes.Buffer, key, value string) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString(key)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(value)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
|
||||
func newMessageID() string {
|
||||
host, err := os.Hostname()
|
||||
if err != nil || host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
var random [8]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), host)
|
||||
}
|
||||
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(random[:]), host)
|
||||
}
|
||||
|
||||
func extractHeader(raw []byte, name string) string {
|
||||
prefix := strings.ToLower(name) + ":"
|
||||
for _, line := range strings.Split(string(raw), "\r\n") {
|
||||
if strings.HasPrefix(strings.ToLower(line), prefix) {
|
||||
return strings.TrimSpace(line[len(name)+1:])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user