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