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

454 lines
15 KiB
Go

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