361 lines
11 KiB
Go
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 ""
|
|
}
|