253 lines
6.6 KiB
Go
253 lines
6.6 KiB
Go
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 ""
|
|
}
|