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

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