Implement internal mail skill MVP

This commit is contained in:
赵义仑
2026-06-06 22:28:36 +08:00
commit 097df73495
22 changed files with 4059 additions and 0 deletions

View 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
View 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 ""
}