709 lines
24 KiB
Go
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, " ", " ")
|
|
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) |