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

709 lines
24 KiB
Go

package provider
// EmailProvider implements ChannelProvider + PollingProvider for Email (IMAP/SMTP) channels.
// Reference: Chatwoot app/models/channel/email.rb
// + app/services/channel/email/fetch_imap_email_service.rb (IMAP polling)
// + app/services/channel/email/process_mail_service.rb (inbound processing)
// + app/mailboxes/channel_email_mailbox.rb (ActionMailbox inbound)
// + app/services/email_reply/send_on_email_service.rb (outbound SMTP)
//
// Chatwoot Email channel architecture:
// - IMAP: periodic fetch via Scheduler → FetchImapEmailService → ProcessMailService → Conversation+Message
// - ActionMailbox: inbound email relay via Postfix → ChannelEmailMailbox → ProcessMailService
// - SMTP: outgoing replies via SendOnEmailService → SMTP connection
// - Forward-to: alternative to IMAP, forwards email to Chatwoot's address
//
// Email is a PollingProvider because IMAP requires periodic fetch cycles,
// unlike webhook-based channels (Telegram, Facebook, etc.).
//
// IMAP and SMTP operations are delegated to the email sub-package
// (internal/channel/email/) to keep provider.go free of mail library imports.
import (
"context"
"encoding/json"
"fmt"
"net/mail"
"strings"
"time"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
)
// === EmailProvider struct ===
// EmailProvider implements ChannelProvider and PollingProvider for Email channels.
// It manages IMAP polling for inbound messages and SMTP sending for outbound.
// Actual IMAP/SMTP operations are delegated to the email sub-package.
type EmailProvider struct {
pollInterval time.Duration // default IMAP polling interval
// emailService will be injected via SetService after bootstrap wiring
emailService EmailServiceInterface
}
// EmailServiceInterface defines the operations delegated to the email sub-package.
// This allows the provider to call IMAP/SMTP operations without importing mail libraries.
type EmailServiceInterface interface {
// ValidateIMAPConnection checks IMAP server connectivity and auth.
ValidateIMAPConnection(config channel.ChannelConfig) error
// ValidateSMTPConnection checks SMTP server connectivity and auth.
ValidateSMTPConnection(config channel.ChannelConfig) error
// FetchIMAPMessages polls IMAP for new messages and converts them to IncomingMessage.
FetchIMAPMessages(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) ([]*channel.IncomingMessage, error)
// SendViaSMTP sends an outgoing email and returns the message ID.
SendViaSMTP(config channel.ChannelConfig, from, to, subject, body string) (string, error)
}
// NewEmailProvider creates an Email provider with default polling interval.
func NewEmailProvider() *EmailProvider {
pollInterval := 5 * time.Minute // default 5-minute IMAP poll interval
return &EmailProvider{
pollInterval: pollInterval,
}
}
// SetService injects the email service implementation (called during bootstrap).
func (p *EmailProvider) SetService(service EmailServiceInterface) {
p.emailService = service
}
// === Identity & Metadata ===
func (p *EmailProvider) Type() channel.ChannelType {
return channel.ChannelEmail
}
func (p *EmailProvider) Name() string {
return "Email"
}
func (p *EmailProvider) Description() string {
return "Connect an email inbox via IMAP/SMTP to handle customer conversations"
}
// === Configuration ===
func (p *EmailProvider) ConfigSchema() *channel.ConfigSchemaDefinition {
return &channel.ConfigSchemaDefinition{
Type: "object",
Properties: map[string]channel.ConfigProperty{
"email": {
Type: "string",
Description: "The inbound email address (e.g. support@acme.com)",
Format: "email",
Required: true,
},
"forward_to_address": {
Type: "string",
Description: "Email address for forwarding (alternative to IMAP polling)",
Format: "email",
},
"domain": {
Type: "string",
Description: "Email domain for routing (e.g. acme.com)",
},
"mailbox_name": {
Type: "string",
Description: "Friendly name for the mailbox (for display)",
Default: "Support",
},
"imap_enabled": {
Type: "boolean",
Description: "Whether IMAP polling is enabled",
Default: true,
},
"imap_address": {
Type: "string",
Description: "IMAP server hostname (e.g. imap.gmail.com)",
},
"imap_port": {
Type: "number",
Description: "IMAP server port (typically 993 for SSL)",
Default: 993,
},
"imap_login": {
Type: "string",
Description: "IMAP login/username",
},
"imap_password": {
Type: "string",
Description: "IMAP password",
Secret: true,
},
"imap_ssl_mode": {
Type: "string",
Description: "SSL mode for IMAP connection",
Enum: []string{"none", "starttls", "ssl"},
Default: "ssl",
},
"smtp_enabled": {
Type: "boolean",
Description: "Whether SMTP sending is enabled",
Default: true,
},
"smtp_address": {
Type: "string",
Description: "SMTP server hostname (e.g. smtp.gmail.com)",
},
"smtp_port": {
Type: "number",
Description: "SMTP server port (typically 587 for TLS)",
Default: 587,
},
"smtp_login": {
Type: "string",
Description: "SMTP login/username",
},
"smtp_password": {
Type: "string",
Description: "SMTP password",
Secret: true,
},
"smtp_ssl_mode": {
Type: "string",
Description: "SSL mode for SMTP connection",
Enum: []string{"none", "starttls", "ssl"},
Default: "starttls",
},
"use_reply_to": {
Type: "boolean",
Description: "Use Reply-To header for contact identification instead of From",
Default: false,
},
},
Required: []string{"email"},
}
}
func (p *EmailProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error {
email, ok := config["email"].(string)
if !ok || email == "" {
return fmt.Errorf("email is required")
}
// Validate email format
if _, err := mail.ParseAddress(email); err != nil {
return fmt.Errorf("email format is invalid: %w", err)
}
// If IMAP is enabled, validate IMAP fields
imapEnabled, _ := config["imap_enabled"].(bool)
if imapEnabled {
imapAddress, _ := config["imap_address"].(string)
if imapAddress == "" {
return fmt.Errorf("imap_address is required when IMAP is enabled")
}
imapLogin, _ := config["imap_login"].(string)
if imapLogin == "" {
return fmt.Errorf("imap_login is required when IMAP is enabled")
}
imapPassword, _ := config["imap_password"].(string)
if imapPassword == "" {
return fmt.Errorf("imap_password is required when IMAP is enabled")
}
imapPort, _ := config["imap_port"].(float64)
if imapPort == 0 {
// default to 993
} else if imapPort < 1 || imapPort > 65535 {
return fmt.Errorf("imap_port must be between 1 and 65535")
}
// Try IMAP connection validation via email service
// Reference: Chatwoot before_create :ensure_imap_connection
if p.emailService != nil {
if err := p.emailService.ValidateIMAPConnection(config); err != nil {
return fmt.Errorf("IMAP connection validation failed: %w", err)
}
}
}
// If SMTP is enabled, validate SMTP fields
smtpEnabled, _ := config["smtp_enabled"].(bool)
if smtpEnabled {
smtpAddress, _ := config["smtp_address"].(string)
if smtpAddress == "" {
return fmt.Errorf("smtp_address is required when SMTP is enabled")
}
smtpLogin, _ := config["smtp_login"].(string)
if smtpLogin == "" {
return fmt.Errorf("smtp_login is required when SMTP is enabled")
}
smtpPassword, _ := config["smtp_password"].(string)
if smtpPassword == "" {
return fmt.Errorf("smtp_password is required when SMTP is enabled")
}
smtpPort, _ := config["smtp_port"].(float64)
if smtpPort == 0 {
// default to 587
} else if smtpPort < 1 || smtpPort > 65535 {
return fmt.Errorf("smtp_port must be between 1 and 65535")
}
// Try SMTP connection validation via email service
// Reference: Chatwoot before_create :ensure_smtp_connection
if p.emailService != nil {
if err := p.emailService.ValidateSMTPConnection(config); err != nil {
return fmt.Errorf("SMTP connection validation failed: %w", err)
}
}
}
return nil
}
func (p *EmailProvider) DefaultConfig() channel.ChannelConfig {
return channel.ChannelConfig{
"email": "",
"forward_to_address": "",
"domain": "",
"mailbox_name": "Support",
"imap_enabled": true,
"imap_address": "",
"imap_port": 993,
"imap_login": "",
"imap_password": "",
"imap_ssl_mode": "ssl",
"smtp_enabled": true,
"smtp_address": "",
"smtp_port": 587,
"smtp_login": "",
"smtp_password": "",
"smtp_ssl_mode": "starttls",
"use_reply_to": false,
}
}
// === Lifecycle ===
func (p *EmailProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) {
// Generate forward_to_address if not provided
// Reference: Chatwoot before_validation :ensure_forward_to_address
email, _ := config["email"].(string)
if email == "" {
return config, fmt.Errorf("email is required")
}
forwardTo, _ := config["forward_to_address"].(string)
if forwardTo == "" {
// Generate a forward-to address based on the inbox
// Reference: Chatwoot generates: {account_name}-{inbox_name}@{domain}
forwardTo = fmt.Sprintf("forward_%d@gochat.local", inbox.ID)
config["forward_to_address"] = forwardTo
}
// Extract domain from email address if not provided
domain, _ := config["domain"].(string)
if domain == "" {
parts := strings.SplitN(email, "@", 2)
if len(parts) == 2 {
domain = parts[1]
config["domain"] = domain
}
}
// Validate IMAP/SMTP connections if enabled and service is available
// Reference: Chatwoot before_create :ensure_imap_connection + :ensure_smtp_connection
if p.emailService != nil {
imapEnabled, _ := config["imap_enabled"].(bool)
if imapEnabled {
if err := p.emailService.ValidateIMAPConnection(config); err != nil {
applogger.L().Warn("IMAP connection validation failed on channel create", "error", err)
config["imap_enabled"] = false // disable IMAP if connection fails
}
}
smtpEnabled, _ := config["smtp_enabled"].(bool)
if smtpEnabled {
if err := p.emailService.ValidateSMTPConnection(config); err != nil {
applogger.L().Warn("SMTP connection validation failed on channel create", "error", err)
config["smtp_enabled"] = false // disable SMTP if connection fails
}
}
}
applogger.L().Info("Email channel created",
"email", email,
"forward_to", forwardTo,
"domain", domain,
)
return config, nil
}
func (p *EmailProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error {
// Email channel cleanup: no external resources like webhooks to remove
email, _ := config["email"].(string)
applogger.L().Info("Email channel destroyed",
"inbox_id", inbox.ID,
"email", email,
)
return nil
}
// === Inbound ===
func (p *EmailProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) {
// Parse the email payload (JSON-serialized EmailMessage from IMAP poll or relay)
var emailMsg EmailMessage
if err := json.Unmarshal(rawPayload, &emailMsg); err != nil {
return nil, fmt.Errorf("failed to parse email payload: %w", err)
}
// Determine sender using From or Reply-To header
// Reference: Chatwoot's use_reply_to flag
senderEmail := emailMsg.From
senderName := emailMsg.FromName
useReplyTo := inboxConfigValue(inbox, "use_reply_to")
if useReplyTo == "true" && emailMsg.ReplyTo != "" {
senderEmail = emailMsg.ReplyTo
senderName = emailMsg.ReplyToName
}
// Determine content: prefer text, fall back to HTML stripped
content := emailMsg.TextContent
if content == "" && emailMsg.HTMLContent != "" {
// In production: use bluemonday (already in go.mod) for HTML sanitization
// Reference: Chatwoot's HTML sanitization + text fallback
content = stripHTMLTags(emailMsg.HTMLContent)
}
contentType := channel.ContentText
if emailMsg.HTMLContent != "" && emailMsg.TextContent == "" {
contentType = channel.ContentEmail
}
incoming := &channel.IncomingMessage{
ChannelType: channel.ChannelEmail,
SourceID: emailMsg.MessageID, // RFC 822 Message-ID header
ConversationID: emailMsg.ThreadID, // from In-Reply-To + References headers
SenderID: senderEmail,
SenderName: senderName,
SenderType: channel.SenderContact,
Content: content,
ContentType: contentType,
InboxID: inbox.ID,
AccountID: inbox.AccountID,
ReceivedAt: emailMsg.Date,
Extra: channel.ChannelConfig{
"subject": emailMsg.Subject,
"from": emailMsg.From,
"from_name": emailMsg.FromName,
"to": emailMsg.To,
"cc": emailMsg.CC,
"bcc": emailMsg.BCC,
"reply_to": emailMsg.ReplyTo,
"reply_to_name": emailMsg.ReplyToName,
"in_reply_to": emailMsg.InReplyTo,
"references": emailMsg.References,
"message_id": emailMsg.MessageID,
"has_html": emailMsg.HTMLContent != "",
"has_text": emailMsg.TextContent != "",
},
}
// Handle attachments
if len(emailMsg.Attachments) > 0 {
incoming.Attachments = make([]channel.Attachment, len(emailMsg.Attachments))
for i, att := range emailMsg.Attachments {
incoming.Attachments[i] = channel.Attachment{
URL: att.URL,
ContentType: att.ContentType,
Filename: att.Filename,
FileSize: att.FileSize,
Extra: channel.ChannelConfig{
"content_id": att.ContentID, // for inline images
"disposition": att.Disposition, // inline or attachment
},
}
}
}
// If this is a reply (has In-Reply-To), set ReplyToID
if emailMsg.InReplyTo != "" {
incoming.ReplyToID = emailMsg.InReplyTo
}
return incoming, nil
}
func (p *EmailProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channel.WebhookRequest) error {
// Email channel doesn't use traditional HTTP webhooks
// Messages come via IMAP polling or ActionMailbox (email relay)
// For ActionMailbox-style relay, validation would use SMTP AUTH or IP whitelist
return nil
}
// === Outbound ===
func (p *EmailProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) {
// Send outbound email via SMTP
// Reference: Chatwoot's SendOnEmailService
if p.emailService == nil {
return nil, fmt.Errorf("email service not initialized")
}
// Parse inbox config
config, err := inboxConfigMap(inbox)
if err != nil {
return nil, fmt.Errorf("failed to parse inbox config: %w", err)
}
smtpEnabled, _ := config["smtp_enabled"].(bool)
if !smtpEnabled {
return nil, fmt.Errorf("SMTP is not enabled for this email channel")
}
smtpAddress, _ := config["smtp_address"].(string)
if smtpAddress == "" {
return nil, fmt.Errorf("smtp_address is not configured")
}
fromEmail, _ := config["email"].(string)
toEmail := contact.Identifier // contact identifier is the email address
if toEmail == "" {
return nil, fmt.Errorf("contact has no email address")
}
// Build subject from conversation context or message content
subject := buildEmailSubject(inbox, message, config)
// Send via SMTP through the email service
externalID, err := p.emailService.SendViaSMTP(config, fromEmail, toEmail, subject, message.Content)
if err != nil {
return nil, fmt.Errorf("failed to send email via SMTP: %w", err)
}
return &channel.SendResult{
ExternalID: externalID,
DeliveredAt: time.Now(),
Extra: channel.ChannelConfig{
"from": fromEmail,
"to": toEmail,
"subject": subject,
"smtp_host": smtpAddress,
},
}, nil
}
// === Contact ===
func (p *EmailProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) {
// Email contacts are identified by their email address
// No external profile API — just use the email address as identifier
// Reference: Chatwoot resolves contact by email → find_or_create_by_email
return &channel.ContactProfile{
Name: contactSource, // email address or display name from From header
Extra: channel.ChannelConfig{
"source": "email",
"email": contactSource,
},
}, nil
}
// === Capabilities ===
func (p *EmailProvider) Capabilities() channel.ChannelCapabilities {
return channel.ChannelCapabilities{
SupportsAttachments: true, // email attachments
SupportsLocation: false,
SupportsTypingIndicator: false, // no typing indicator in email
SupportsDeliveryStatus: false, // no delivery/read receipts via SMTP
SupportsReplies: true, // In-Reply-To + References threading
SupportsEmojiReactions: false,
SupportsVoiceMessages: false,
SupportsVideoCalls: false,
SupportsCustomCards: false,
SupportsTemplates: false,
SupportsEmailHeaders: true, // Subject, CC, BCC, Reply-To headers
MaxAttachmentSize: 25 * 1024 * 1024, // 25MB typical email limit
MaxTextLength: 0, // unlimited (email body)
}
}
// === PollingProvider interface ===
func (p *EmailProvider) PollInterval() time.Duration {
return p.pollInterval
}
func (p *EmailProvider) PollMessages(ctx context.Context, inbox *model.Inbox) ([]*channel.IncomingMessage, error) {
// Poll for new emails via IMAP
// Reference: Chatwoot's Imap::FetchService + Scheduler periodic fetch
if p.emailService == nil {
applogger.L().Warn("Email service not initialized, skipping IMAP poll",
"inbox_id", inbox.ID,
)
return nil, nil
}
// Parse inbox config
config, err := inboxConfigMap(inbox)
if err != nil {
applogger.L().Error("Failed to parse inbox config for IMAP poll",
"inbox_id", inbox.ID,
"error", err,
)
return nil, nil
}
imapEnabled, _ := config["imap_enabled"].(bool)
if !imapEnabled {
return nil, nil // IMAP not enabled — skip polling
}
imapAddress, _ := config["imap_address"].(string)
imapLogin, _ := config["imap_login"].(string)
imapPassword, _ := config["imap_password"].(string)
if imapAddress == "" || imapLogin == "" || imapPassword == "" {
applogger.L().Warn("IMAP configuration incomplete, skipping poll",
"inbox_id", inbox.ID,
)
return nil, nil
}
// Delegate to email service for IMAP fetch
messages, err := p.emailService.FetchIMAPMessages(ctx, inbox, config)
if err != nil {
applogger.L().Error("IMAP poll failed",
"inbox_id", inbox.ID,
"error", err,
)
return nil, fmt.Errorf("IMAP poll failed: %w", err)
}
applogger.L().Info("IMAP poll completed",
"inbox_id", inbox.ID,
"messages_fetched", len(messages),
)
return messages, nil
}
// === Helper functions ===
// inboxConfigMap parses the Inbox's JSON-encoded ChannelConfig string into a map.
// Inbox.ChannelConfig is stored as a JSON string in the database.
func inboxConfigMap(inbox *model.Inbox) (channel.ChannelConfig, error) {
if inbox.ChannelConfig == "" {
return channel.ChannelConfig{}, nil
}
var config channel.ChannelConfig
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
return nil, fmt.Errorf("failed to parse inbox ChannelConfig JSON: %w", err)
}
return config, nil
}
// inboxConfigValue extracts a single config value from the Inbox's ChannelConfig.
// Returns empty string if key not found or config is empty.
func inboxConfigValue(inbox *model.Inbox, key string) string {
config, err := inboxConfigMap(inbox)
if err != nil || config == nil {
return ""
}
v, ok := config[key]
if !ok {
return ""
}
switch val := v.(type) {
case string:
return val
case bool:
if val {
return "true"
}
return "false"
case float64:
return fmt.Sprintf("%.0f", val)
case int:
return fmt.Sprintf("%d", val)
default:
return fmt.Sprintf("%v", val)
}
}
// stripHTMLTags removes basic HTML tags from content.
// In production: use bluemonday (already in go.mod) for proper sanitization
// Reference: Chatwoot's HTML sanitization
func stripHTMLTags(html string) string {
result := html
for {
start := strings.Index(result, "<")
if start == -1 {
break
}
end := strings.Index(result[start:], ">")
if end == -1 {
break
}
result = result[:start] + result[start+end+1:]
}
result = strings.ReplaceAll(result, "&nbsp;", " ")
result = strings.ReplaceAll(result, "&amp;", "&")
result = strings.ReplaceAll(result, "&lt;", "<")
result = strings.ReplaceAll(result, "&gt;", ">")
result = strings.ReplaceAll(result, "&#39;", "'")
result = strings.ReplaceAll(result, "&quot;", "\"")
return strings.TrimSpace(result)
}
// buildEmailSubject constructs an email subject for outgoing messages.
// Reference: Chatwoot's subject construction for SendOnEmailService
func buildEmailSubject(inbox *model.Inbox, message *model.Message, config channel.ChannelConfig) string {
mailboxName, _ := config["mailbox_name"].(string)
if mailboxName == "" {
mailboxName = "Support"
}
// Truncate content for subject preview
content := message.Content
if len(content) > 50 {
content = content[:50] + "..."
}
return fmt.Sprintf("[%s] %s", mailboxName, content)
}
// init registers EmailProvider with the global channel registry.
func init() {
channel.MustRegister(NewEmailProvider())
}
// === Email Message Types ===
// EmailMessage is the parsed representation of an inbound email.
// Reference: Chatwoot's ProcessMailService input — parsed from IMAP or ActionMailbox
type EmailMessage struct {
MessageID string `json:"message_id"` // RFC 822 Message-ID
ThreadID string `json:"thread_id"` // conversation thread ID (from References + In-Reply-To)
From string `json:"from"` // sender email
FromName string `json:"from_name"` // sender display name
To string `json:"to"` // recipient email
CC string `json:"cc,omitempty"` // CC recipients
BCC string `json:"bcc,omitempty"` // BCC recipients
ReplyTo string `json:"reply_to,omitempty"` // Reply-To header
ReplyToName string `json:"reply_to_name,omitempty"` // Reply-To display name
InReplyTo string `json:"in_reply_to,omitempty"` // In-Reply-To Message-ID
References string `json:"references,omitempty"` // References header (thread chain)
Subject string `json:"subject"` // email subject
TextContent string `json:"text_content"` // plain text body
HTMLContent string `json:"html_content,omitempty"` // HTML body
Date time.Time `json:"date"` // email date
Attachments []EmailAttachment `json:"attachments,omitempty"` // email attachments
}
// EmailAttachment represents an attachment in an inbound email.
type EmailAttachment struct {
URL string `json:"url"` // download URL or local path
ContentType string `json:"content_type"` // MIME type
Filename string `json:"filename,omitempty"` // original filename
FileSize int64 `json:"file_size,omitempty"` // file size in bytes
ContentID string `json:"content_id,omitempty"` // Content-ID for inline images
Disposition string `json:"disposition,omitempty"` // inline or attachment
}
// Compile-time interface check: EmailProvider must satisfy ChannelProvider + PollingProvider.
var _ channel.ChannelProvider = (*EmailProvider)(nil)
var _ channel.PollingProvider = (*EmailProvider)(nil)