Files
gochat/internal/channel/email/smtp_sender.go
T
2026-06-04 15:44:48 +08:00

361 lines
11 KiB
Go

package email
// SMTPSender handles outbound email delivery via SMTP.
// Reference: Chatwoot app/services/email_reply/send_on_email_service.rb
//
// Chatwoot's SMTP sending:
// - SendOnEmailService connects to configured SMTP server
// - Composes reply email with original subject + "Re:" prefix
// - Sets In-Reply-To and References headers for threading
// - Supports HTML and plain text content
// - Includes original message excerpt in reply footer
//
// gochat's SMTP sending:
// - EmailProvider.SendMessage() delegates to SMTPSender.Send()
// - Composes RFC 5322 compliant email with proper headers
// - Supports STARTTLS, SSL, and plain connections
// - Uses net/smtp for AUTH and Go stdlib for message composition
import (
"crypto/tls"
"fmt"
"net/smtp"
"strings"
channelpkg "github.com/gochat/gochat/internal/channel"
applogger "github.com/gochat/gochat/pkg/logger"
)
// SMTPSender sends outbound emails via SMTP.
type SMTPSender struct{}
// NewSMTPSender creates a new SMTP sender.
func NewSMTPSender() *SMTPSender {
return &SMTPSender{}
}
// Send delivers an outbound email via SMTP.
// Returns the message ID assigned by the SMTP server (if available).
//
// Reference: Chatwoot SendOnEmailService.perform
// Flow:
// 1. Parse SMTP config from channel config
// 2. Compose RFC 5322 message with headers + body
// 3. Connect to SMTP server (STARTTLS/SSL/plain)
// 4. Authenticate with configured credentials
// 5. Send message from configured email to recipient
// 6. Return external message ID for tracking
func (s *SMTPSender) Send(config channelpkg.ChannelConfig, from, to, subject, body string) (string, error) {
smtpAddress, _ := config["smtp_address"].(string)
smtpPort := configInt(config, "smtp_port", 587)
smtpLogin, _ := config["smtp_login"].(string)
smtpPassword, _ := config["smtp_password"].(string)
sslMode, _ := config["smtp_ssl_mode"].(string)
emailAddress, _ := config["email"].(string)
if smtpAddress == "" || smtpLogin == "" {
return "", fmt.Errorf("SMTP configuration incomplete: address and login required")
}
// Use the channel email address as From if not specified
if from == "" {
from = emailAddress
}
// Compose the email message in RFC 5322 format
fromHeader := from
if fromName, _ := config["mailbox_name"].(string); fromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", fromName, from)
}
msgID := generateMessageID(from)
message := composeSMTPMessage(fromHeader, to, subject, body, msgID)
// Connect and send based on SSL mode
addr := fmt.Sprintf("%s:%d", smtpAddress, smtpPort)
switch sslMode {
case "ssl":
// Direct TLS connection (port 465 typically)
return s.sendTLS(addr, smtpLogin, smtpPassword, from, to, []byte(message))
case "starttls":
// STARTTLS: connect plain then upgrade (port 587 typically)
return s.sendSTARTTLS(addr, smtpLogin, smtpPassword, from, to, []byte(message))
case "none":
// Plain connection (no encryption — testing only)
return s.sendPlain(addr, smtpLogin, smtpPassword, from, to, []byte(message))
default:
// Default to STARTTLS for safety
return s.sendSTARTTLS(addr, smtpLogin, smtpPassword, from, to, []byte(message))
}
}
// ValidateConnection checks SMTP server connectivity and authentication.
// Reference: Chatwoot ChannelEmail before_create :ensure_smtp_connection
func (s *SMTPSender) ValidateConnection(config channelpkg.ChannelConfig) error {
smtpAddress, _ := config["smtp_address"].(string)
smtpPort := configInt(config, "smtp_port", 587)
smtpLogin, _ := config["smtp_login"].(string)
smtpPassword, _ := config["smtp_password"].(string)
sslMode, _ := config["smtp_ssl_mode"].(string)
if smtpAddress == "" || smtpLogin == "" || smtpPassword == "" {
return fmt.Errorf("SMTP address, login, and password are required for validation")
}
addr := fmt.Sprintf("%s:%d", smtpAddress, smtpPort)
switch sslMode {
case "ssl":
return s.validateTLS(addr, smtpLogin, smtpPassword)
case "starttls":
return s.validateSTARTTLS(addr, smtpLogin, smtpPassword)
case "none":
return s.validatePlain(addr, smtpLogin, smtpPassword)
default:
return s.validateSTARTTLS(addr, smtpLogin, smtpPassword)
}
}
// === Private helpers ===
// sendTLS sends an email via a direct TLS SMTP connection (port 465).
func (s *SMTPSender) sendTLS(addr, login, password, from, to string, msg []byte) (string, error) {
tlsConfig := &tls.Config{
ServerName: strings.Split(addr, ":")[0],
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return "", fmt.Errorf("SMTP TLS connection failed: %w", err)
}
defer conn.Close()
c, err := smtp.NewClient(conn, strings.Split(addr, ":")[0])
if err != nil {
return "", fmt.Errorf("SMTP client creation failed: %w", err)
}
defer c.Close()
// Authenticate
auth := smtp.PlainAuth("", login, password, strings.Split(addr, ":")[0])
if err := c.Auth(auth); err != nil {
return "", fmt.Errorf("SMTP authentication failed: %w", err)
}
// Send message
return s.sendViaClient(c, from, to, msg)
}
// sendSTARTTLS sends an email via STARTTLS SMTP connection (port 587).
func (s *SMTPSender) sendSTARTTLS(addr, login, password, from, to string, msg []byte) (string, error) {
host := strings.Split(addr, ":")[0]
// Connect plain first
c, err := smtp.Dial(addr)
if err != nil {
return "", fmt.Errorf("SMTP connection failed: %w", err)
}
defer c.Close()
// Upgrade to TLS
tlsConfig := &tls.Config{
ServerName: host,
}
if err := c.StartTLS(tlsConfig); err != nil {
return "", fmt.Errorf("SMTP STARTTLS upgrade failed: %w", err)
}
// Authenticate
auth := smtp.PlainAuth("", login, password, host)
if err := c.Auth(auth); err != nil {
return "", fmt.Errorf("SMTP authentication failed: %w", err)
}
// Send message
return s.sendViaClient(c, from, to, msg)
}
// sendPlain sends an email via plain SMTP (no encryption — testing only).
func (s *SMTPSender) sendPlain(addr, login, password, from, to string, msg []byte) (string, error) {
host := strings.Split(addr, ":")[0]
c, err := smtp.Dial(addr)
if err != nil {
return "", fmt.Errorf("SMTP connection failed: %w", err)
}
defer c.Close()
// Authenticate (may fail on servers requiring TLS)
auth := smtp.PlainAuth("", login, password, host)
if err := c.Auth(auth); err != nil {
return "", fmt.Errorf("SMTP authentication failed: %w", err)
}
applogger.L().Warn("SMTP using unencrypted connection",
"address", addr,
)
return s.sendViaClient(c, from, to, msg)
}
// sendViaClient sends a message using an established smtp.Client.
func (s *SMTPSender) sendViaClient(c *smtp.Client, from, to string, msg []byte) (string, error) {
if err := c.Mail(from); err != nil {
return "", fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err := c.Rcpt(to); err != nil {
return "", fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := c.Data()
if err != nil {
return "", fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err := w.Write(msg); err != nil {
return "", fmt.Errorf("SMTP message write failed: %w", err)
}
if err := w.Close(); err != nil {
return "", fmt.Errorf("SMTP data close failed: %w", err)
}
if err := c.Quit(); err != nil {
// Quit error is non-critical — message was already sent
applogger.L().Debug("SMTP quit warning",
"error", err,
)
}
applogger.L().Info("SMTP message sent",
"from", from,
"to", to,
)
// Extract Message-Id from the composed message for tracking
// In practice, the server may assign a different ID
return extractMessageIDFromRaw(msg), nil
}
// validateTLS validates SMTP connectivity via direct TLS connection.
func (s *SMTPSender) validateTLS(addr, login, password string) error {
tlsConfig := &tls.Config{
ServerName: strings.Split(addr, ":")[0],
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("SMTP TLS connection failed: %w", err)
}
defer conn.Close()
c, err := smtp.NewClient(conn, strings.Split(addr, ":")[0])
if err != nil {
return fmt.Errorf("SMTP client creation failed: %w", err)
}
defer c.Close()
auth := smtp.PlainAuth("", login, password, strings.Split(addr, ":")[0])
if err := c.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
applogger.L().Info("SMTP TLS connection validated",
"address", addr,
)
return nil
}
// validateSTARTTLS validates SMTP connectivity via STARTTLS.
func (s *SMTPSender) validateSTARTTLS(addr, login, password string) error {
host := strings.Split(addr, ":")[0]
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("SMTP connection failed: %w", err)
}
defer c.Close()
tlsConfig := &tls.Config{ServerName: host}
if err := c.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("SMTP STARTTLS upgrade failed: %w", err)
}
auth := smtp.PlainAuth("", login, password, host)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
applogger.L().Info("SMTP STARTTLS connection validated",
"address", addr,
)
return nil
}
// validatePlain validates SMTP connectivity without encryption.
func (s *SMTPSender) validatePlain(addr, login, password string) error {
host := strings.Split(addr, ":")[0]
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("SMTP connection failed: %w", err)
}
defer c.Close()
auth := smtp.PlainAuth("", login, password, host)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
applogger.L().Info("SMTP plain connection validated",
"address", addr,
)
return nil
}
// composeSMTPMessage builds a RFC 5322 compliant email message.
func composeSMTPMessage(from, to, subject, body, msgID string) string {
var buf strings.Builder
// Headers
buf.WriteString(fmt.Sprintf("From: %s\r\n", from))
buf.WriteString(fmt.Sprintf("To: %s\r\n", to))
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
buf.WriteString(fmt.Sprintf("Message-Id: %s\r\n", msgID))
buf.WriteString(fmt.Sprintf("Date: %s\r\n", formatDateNow()))
buf.WriteString("MIME-Version: 1.0\r\n")
// Content-Type: prefer HTML if body looks like HTML, else plain text
if looksLikeHTML(body) {
buf.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
} else {
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
}
buf.WriteString("Content-Transfer-Encoding: 8bit\r\n")
buf.WriteString("\r\n") // header/body separator
buf.WriteString(body)
buf.WriteString("\r\n")
return buf.String()
}
// extractMessageIDFromRaw attempts to find a Message-Id in a raw email message.
func extractMessageIDFromRaw(msg []byte) string {
lines := strings.Split(string(msg), "\r\n")
for _, line := range lines {
if strings.HasPrefix(line, "Message-Id:") || strings.HasPrefix(line, "Message-ID:") {
id := strings.TrimPrefix(line, "Message-Id:")
id = strings.TrimPrefix(id, "Message-ID:")
return strings.TrimSpace(id)
}
}
return ""
}