package email // EmailProvider implements channel.ChannelProvider + channel.PollingProvider for Email (IMAP+SMTP). // Reference: Chatwoot app/models/channel/email.rb + ChannelEmail lifecycle hooks // // Email channel lifecycle: // - OnCreate: validate IMAP+SMTP connections, set forward_to_address // - Incoming (IMAP): PollMessages → FetchIMAPMessages → IncomingProcessor.Process // - Incoming (Webhook): HandleWebhookRequest → ParseWebhookBody → IncomingProcessor.Process // - Outgoing: SendMessage → SendViaSMTP // - OnDestroy: no cleanup needed (IMAP/SMTP connections are per-session) // // Design notes: // - Email is a PollingProvider (IMAP polling) as well as a ChannelProvider (webhook relay) // - The two incoming paths (IMAP poll + webhook relay) converge at IncomingProcessor // - SMTPSender handles outbound delivery; IMAPListener handles inbound polling // - forward_to_address is auto-generated for ActionMailbox-style email relay import ( "context" "encoding/json" "fmt" "time" channelpkg "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // EmailProvider implements ChannelProvider and PollingProvider for Email. type EmailProvider struct { service *Service repo *Repository pipeline *IncomingProcessor } // NewEmailProvider creates an Email channel provider with all dependencies. func NewEmailProvider(service *Service, repo *Repository, pipeline *IncomingProcessor) *EmailProvider { return &EmailProvider{ service: service, repo: repo, pipeline: pipeline, } } // === Channel Identity === func (p *EmailProvider) Type() channelpkg.ChannelType { return channelpkg.ChannelEmail } func (p *EmailProvider) Name() string { return "Email" } func (p *EmailProvider) Description() string { return "Email channel (IMAP + SMTP)" } // === Configuration === func (p *EmailProvider) ConfigSchema() *channelpkg.ConfigSchemaDefinition { return &channelpkg.ConfigSchemaDefinition{ Type: "object", Properties: map[string]channelpkg.ConfigProperty{ "imap_address": { Type: "string", Description: "IMAP server hostname (e.g. imap.gmail.com)", Required: true, }, "imap_port": { Type: "number", Description: "IMAP server port (typically 993 for SSL)", Default: 993, Required: true, }, "imap_email": { Type: "string", Description: "IMAP login email address", Format: "email", Required: true, }, "imap_password": { Type: "string", Description: "IMAP password for authentication", Required: true, Secret: true, }, "imap_ssl": { Type: "boolean", Description: "Whether to use SSL for IMAP connection", Default: true, Required: false, }, "smtp_address": { Type: "string", Description: "SMTP server hostname (e.g. smtp.gmail.com)", Required: true, }, "smtp_port": { Type: "number", Description: "SMTP server port (typically 587 for TLS or 465 for SSL)", Default: 587, Required: true, }, "smtp_email": { Type: "string", Description: "SMTP login email address", Format: "email", Required: true, }, "smtp_password": { Type: "string", Description: "SMTP password for authentication", Required: true, Secret: true, }, "smtp_ssl": { Type: "boolean", Description: "Whether to use SSL/TLS for SMTP connection", Default: true, Required: false, }, }, Required: []string{ "imap_address", "imap_port", "imap_email", "imap_password", "smtp_address", "smtp_port", "smtp_email", "smtp_password", }, } } func (p *EmailProvider) ValidateConfig(ctx context.Context, config channelpkg.ChannelConfig) error { imapAddress, _ := config["imap_address"].(string) imapPort := configInt(config, "imap_port", 0) imapEmail, _ := config["imap_email"].(string) imapPassword, _ := config["imap_password"].(string) smtpAddress, _ := config["smtp_address"].(string) smtpPort := configInt(config, "smtp_port", 0) smtpEmail, _ := config["smtp_email"].(string) smtpPassword, _ := config["smtp_password"].(string) if imapAddress == "" { return fmt.Errorf("imap_address is required") } if imapPort == 0 { return fmt.Errorf("imap_port is required") } if imapEmail == "" { return fmt.Errorf("imap_email is required") } if imapPassword == "" { return fmt.Errorf("imap_password is required") } if smtpAddress == "" { return fmt.Errorf("smtp_address is required") } if smtpPort == 0 { return fmt.Errorf("smtp_port is required") } if smtpEmail == "" { return fmt.Errorf("smtp_email is required") } if smtpPassword == "" { return fmt.Errorf("smtp_password is required") } return nil } func (p *EmailProvider) DefaultConfig() channelpkg.ChannelConfig { return channelpkg.ChannelConfig{ "imap_address": "", "imap_port": 993, "imap_email": "", "imap_password": "", "imap_ssl": true, "smtp_address": "", "smtp_port": 587, "smtp_email": "", "smtp_password": "", "smtp_ssl": true, } } // === Lifecycle Hooks === // OnCreate validates IMAP+SMTP connections and sets forward_to_address. // Reference: Chatwoot's before_create :ensure_imap_connection + :ensure_smtp_connection + :ensure_forward_to_address func (p *EmailProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channelpkg.ChannelConfig) (channelpkg.ChannelConfig, error) { // Validate IMAP connection if err := p.service.ValidateIMAPConnection(config); err != nil { applogger.L().Warnf("Email OnCreate: IMAP connection validation failed: %v", err) return nil, fmt.Errorf("IMAP connection validation failed: %w", err) } // Validate SMTP connection if err := p.service.ValidateSMTPConnection(config); err != nil { applogger.L().Warnf("Email OnCreate: SMTP connection validation failed: %v", err) return nil, fmt.Errorf("SMTP connection validation failed: %w", err) } // Set forward_to_address for email relay (ActionMailbox-style) // Reference: Chatwoot's ensure_forward_to_address callback smtpEmail, _ := config["smtp_email"].(string) if smtpEmail != "" { config["forward_to_address"] = smtpEmail } applogger.L().Infof("Email OnCreate: IMAP+SMTP validated for inbox=%d email=%s", inbox.ID, smtpEmail) return config, nil } // OnDestroy handles cleanup before channel destruction. // Email doesn't require external cleanup — IMAP/SMTP connections are per-session. func (p *EmailProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channelpkg.ChannelConfig) error { applogger.L().Infof("Email OnDestroy: channel destroyed for inbox=%d", inbox.ID) return nil } // === Messaging: Inbound === // ProcessIncoming transforms raw email payload into IncomingMessage. // This handles both IMAP-pollled messages and webhook-relayed messages. func (p *EmailProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channelpkg.IncomingMessage, error) { var emailMsg EmailMessage if err := json.Unmarshal(rawPayload, &emailMsg); err != nil { return nil, fmt.Errorf("email ProcessIncoming: failed to parse payload: %w", err) } // Build IncomingMessage from parsed EmailMessage incoming := &channelpkg.IncomingMessage{ ChannelType: channelpkg.ChannelEmail, SourceID: emailMsg.MessageID, ConversationID: emailMsg.InReplyTo, // threading via In-Reply-To SenderID: emailMsg.FromAddress, SenderName: emailMsg.FromName, SenderType: channelpkg.SenderContact, Content: emailMsg.TextContent, ContentType: channelpkg.ContentEmail, InboxID: inbox.ID, AccountID: inbox.AccountID, ReceivedAt: time.Now(), Extra: channelpkg.ChannelConfig{ "subject": emailMsg.Subject, "html_content": emailMsg.HTMLContent, "reply_to_address": emailMsg.ReplyToAddress, "to_addresses": emailMsg.ToAddresses, "cc_addresses": emailMsg.CcAddresses, "in_reply_to": emailMsg.InReplyTo, "references": emailMsg.References, "imap_uid": emailMsg.UID, }, } // Convert attachments for _, att := range emailMsg.Attachments { incoming.Attachments = append(incoming.Attachments, channelpkg.Attachment{ URL: att.URL, ContentType: att.ContentType, Filename: att.Filename, FileSize: att.FileSize, }) } // If there's a reply-to reference, set ReplyToID if emailMsg.InReplyTo != "" { incoming.ReplyToID = emailMsg.InReplyTo } return incoming, nil } // ValidateWebhookRequest verifies email webhook relay request authenticity. // For email relay webhooks, we accept the request as-is since the relay // service (Postfix, SES, Mailgun) handles its own authentication. // Additional verification can be added per relay provider. func (p *EmailProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channelpkg.WebhookRequest) error { // Email webhook relay providers handle their own authentication: // - Mailgun: HMAC signature verification // - SendGrid: API key in headers // - SES: SNS signature verification // - Generic: basic auth or IP whitelist // // For now, we accept all relay requests. Per-provider verification // can be added based on the relay source identified in headers. return nil } // === Messaging: Outbound === // SendMessage sends an outgoing email via SMTP. // Reference: Chatwoot's SendOnEmailService func (p *EmailProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channelpkg.SendResult, error) { config := parseInboxConfig(inbox.ChannelConfig) // Determine recipient email address toAddress := contact.SourceID if toAddress == "" { return nil, fmt.Errorf("email SendMessage: contact has no source_id (email address)") } // Determine sender email address fromAddress := configStr(config, "smtp_email", "") if fromAddress == "" { return nil, fmt.Errorf("email SendMessage: smtp_email not configured") } // Build subject from message content attributes or default subject := fmt.Sprintf("Message from gochat inbox #%d", inbox.ID) // Send via SMTP body := message.Content messageID, err := p.service.SendViaSMTP(config, fromAddress, toAddress, subject, body) if err != nil { return nil, fmt.Errorf("email SendMessage: SMTP send failed: %w", err) } applogger.L().Infof("Email SendMessage: sent to=%s subject=%s msg_id=%s", toAddress, subject, messageID) return &channelpkg.SendResult{ ExternalID: messageID, DeliveredAt: time.Now(), Extra: channelpkg.ChannelConfig{ "from": fromAddress, "to": toAddress, "subject": subject, }, }, nil } // === Contact Info === // GetContactProfile resolves a contact profile from an email address. // Email contacts are identified by their email address; we construct // a profile using the sender info from the email message headers. func (p *EmailProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channelpkg.ContactProfile, error) { // Email contacts are identified by email address (contactSource). // The profile is constructed from message headers (From name, From address). // No external API call is needed — email doesn't have a profile API. return &channelpkg.ContactProfile{ Name: contactSource, // email address as name; real name comes from message headers Extra: channelpkg.ChannelConfig{ "email": contactSource, }, }, nil } // === Capabilities === func (p *EmailProvider) Capabilities() channelpkg.ChannelCapabilities { return channelpkg.ChannelCapabilities{ SupportsAttachments: true, // email attachments (MIME multipart) SupportsLocation: false, SupportsTypingIndicator: false, SupportsDeliveryStatus: true, // SMTP delivery status notifications (DSN) SupportsReplies: true, // threading via In-Reply-To/References SupportsEmojiReactions: false, SupportsVoiceMessages: false, SupportsVideoCalls: false, SupportsCustomCards: false, SupportsTemplates: false, SupportsEmailHeaders: true, // subject, cc, bcc, reply-to MaxAttachmentSize: 25 * 1024 * 1024, // 25MB (typical email limit) MaxTextLength: 0, // no practical limit for email } } // === PollingProvider Interface === // PollInterval returns the IMAP polling interval. // Reference: Chatwoot's FetchImapEmailService runs every 5 minutes func (p *EmailProvider) PollInterval() time.Duration { return 5 * time.Minute } // PollMessages fetches new email messages via IMAP polling. // Reference: Chatwoot's Imap::FetchService func (p *EmailProvider) PollMessages(ctx context.Context, inbox *model.Inbox) ([]*channelpkg.IncomingMessage, error) { config := parseInboxConfig(inbox.ChannelConfig) // Fetch messages via IMAP incomingMessages, err := p.service.FetchIMAPMessages(ctx, inbox, config) if err != nil { applogger.L().Errorf("Email PollMessages: IMAP fetch failed for inbox=%d: %v", inbox.ID, err) return nil, fmt.Errorf("IMAP fetch failed: %w", err) } if len(incomingMessages) == 0 { return nil, nil } // Process each message through the pipeline for full contact/conversation/message creation for _, msg := range incomingMessages { // Try to process via the pipeline for database persistence // The pipeline requires an EmailMessage, which we reconstruct from the IncomingMessage Extra if msg.Extra != nil { rawPayload, err := json.Marshal(msg) if err != nil { applogger.L().Warnf("Email PollMessages: failed to serialize message source_id=%s: %v", msg.SourceID, err) continue } // Reconstruct EmailMessage from the IncomingMessage's Extra data for pipeline processing var emailMsg EmailMessage if err := json.Unmarshal(rawPayload, &emailMsg); err != nil { applogger.L().Warnf("Email PollMessages: failed to reconstruct EmailMessage for source_id=%s: %v", msg.SourceID, err) continue } if _, err := p.pipeline.Process(ctx, inbox, &emailMsg); err != nil { applogger.L().Warnf("Email PollMessages: pipeline process failed for source_id=%s: %v", msg.SourceID, err) continue } } } applogger.L().Infof("Email PollMessages: fetched %d messages for inbox=%d", len(incomingMessages), inbox.ID) return incomingMessages, nil } // === Helpers === // parseInboxConfig converts the inbox's JSON string ChannelConfig into a ChannelConfig map. func parseInboxConfig(configStr string) channelpkg.ChannelConfig { if configStr == "" { return channelpkg.ChannelConfig{} } var config channelpkg.ChannelConfig if err := json.Unmarshal([]byte(configStr), &config); err != nil { applogger.L().Warnf("Email: failed to parse inbox channel_config JSON: %v", err) return channelpkg.ChannelConfig{} } return config } // configStr extracts a string value from ChannelConfig with a default fallback. func configStr(config channelpkg.ChannelConfig, key, defaultVal string) string { if v, ok := config[key]; ok { if s, ok := v.(string); ok && s != "" { return s } } return defaultVal } // === Registration === // SetPipeline sets the incoming message processor for this provider. // Called by bootstrap after pipeline is created (pipeline depends on provider as IncomingMessageHandler). func (p *EmailProvider) SetPipeline(pipeline *IncomingProcessor) { p.pipeline = pipeline } func init() { // Auto-register Email provider when this package is imported. // Requires dependencies to be injected via NewEmailProvider before use. // The actual registration happens in bootstrap when dependencies are available. }