454 lines
15 KiB
Go
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.
|
|
} |