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, " ", " ") result = strings.ReplaceAll(result, "&", "&") result = strings.ReplaceAll(result, "<", "<") result = strings.ReplaceAll(result, ">", ">") result = strings.ReplaceAll(result, "'", "'") result = strings.ReplaceAll(result, """, "\"") 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)