diff --git a/messenger/messenger.go b/messenger/messenger.go index dfe6bd77..ac940f11 100644 --- a/messenger/messenger.go +++ b/messenger/messenger.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/gin-gonic/gin" "github.com/yaoapp/gou/application" "github.com/yaoapp/kun/log" "github.com/yaoapp/yao/config" @@ -716,99 +717,33 @@ func (m *Service) RemoveReceiveHandler(handler types.MessageHandler) error { // TriggerWebhook processes incoming webhook data and triggers OnReceive handlers // This is used by OPENAPI endpoints to handle incoming messages -func (m *Service) TriggerWebhook(ctx context.Context, providerName string, data map[string]interface{}) error { +func (m *Service) TriggerWebhook(providerName string, c interface{}) error { // Get the provider to process the webhook data provider, exists := m.providers[providerName] if !exists { return fmt.Errorf("provider not found: %s", providerName) } - // First, let the provider process the webhook data - // This may convert webhook data into a standardized message format - err := provider.Receive(ctx, data) + // Let the provider process the webhook data and convert to Message + message, err := provider.TriggerWebhook(c) if err != nil { - log.Warn("[Messenger] Provider %s failed to process webhook data: %v", providerName, err) - // Continue to trigger handlers even if provider processing fails + log.Warn("[Messenger] Provider %s failed to process webhook: %v", providerName, err) + return err } - // Try to convert webhook data to a Message for OnReceive handlers - message, err := m.convertWebhookToMessage(providerName, data) - if err != nil { - log.Warn("[Messenger] Failed to convert webhook data to message: %v", err) - return err + // Create context from gin.Context if available, otherwise use background + var ctx context.Context + if ginCtx, ok := c.(*gin.Context); ok { + ctx = ginCtx.Request.Context() + } else { + ctx = context.Background() } // Trigger all registered OnReceive handlers return m.triggerOnReceiveHandlers(ctx, message) } -// convertWebhookToMessage attempts to convert webhook data to a standardized Message -func (m *Service) convertWebhookToMessage(providerName string, data map[string]interface{}) (*types.Message, error) { - message := &types.Message{ - Metadata: make(map[string]interface{}), - } - - // Add provider information - message.Metadata["provider"] = providerName - message.Metadata["webhook_data"] = data - - // Try to extract common fields from webhook data - if subject, ok := data["subject"].(string); ok { - message.Subject = subject - } - if from, ok := data["from"].(string); ok { - message.From = from - } - if body, ok := data["body"].(string); ok { - message.Body = body - } - if html, ok := data["html"].(string); ok { - message.HTML = html - } - - // Handle "to" field which might be string or array - if to, ok := data["to"]; ok { - switch v := to.(type) { - case string: - message.To = []string{v} - case []string: - message.To = v - case []interface{}: - for _, item := range v { - if str, ok := item.(string); ok { - message.To = append(message.To, str) - } - } - } - } - - // Determine message type based on provider or data - if msgType, ok := data["type"].(string); ok { - message.Type = types.MessageType(strings.ToLower(msgType)) - } else { - // Default based on provider type - provider, exists := m.providers[providerName] - if exists { - switch strings.ToLower(provider.GetType()) { - case "mailer", "smtp", "mailgun": - message.Type = types.MessageTypeEmail - case "twilio": - // Could be SMS, WhatsApp, or Email - try to determine from data - if phone, ok := data["phone"].(string); ok && phone != "" { - message.Type = types.MessageTypeSMS - } else if whatsapp, ok := data["whatsapp"].(string); ok && whatsapp != "" { - message.Type = types.MessageTypeWhatsApp - } else { - message.Type = types.MessageTypeEmail - } - default: - message.Type = types.MessageTypeEmail // Default fallback - } - } - } - - return message, nil -} +// Note: convertWebhookToMessage has been removed as it's replaced by provider-specific TriggerWebhook implementations // triggerOnReceiveHandlers calls all registered OnReceive handlers func (m *Service) triggerOnReceiveHandlers(ctx context.Context, message *types.Message) error { diff --git a/messenger/messenger_onreceive_test.go b/messenger/messenger_onreceive_test.go index ac7bcb67..ef49587e 100644 --- a/messenger/messenger_onreceive_test.go +++ b/messenger/messenger_onreceive_test.go @@ -2,10 +2,14 @@ package messenger import ( "context" + "net/http/httptest" + "net/url" + "strings" "sync" "testing" "time" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yaoapp/yao/config" @@ -13,6 +17,30 @@ import ( "github.com/yaoapp/yao/test" ) +// createMockGinContext creates a mock gin.Context for testing webhook functionality +func createMockGinContext(formData map[string]interface{}) *gin.Context { + // Create form values + values := url.Values{} + for key, value := range formData { + if str, ok := value.(string); ok { + values.Set(key, str) + } + } + + // Create request with form data + req := httptest.NewRequest("POST", "/webhook/test", strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Create response recorder + w := httptest.NewRecorder() + + // Create gin context + c, _ := gin.CreateTestContext(w) + c.Request = req + + return c +} + // Test OnReceive functionality func TestService_OnReceive(t *testing.T) { // Prepare test environment @@ -143,7 +171,6 @@ func TestService_TriggerWebhook(t *testing.T) { } // Test with non-existent provider - ctx := context.Background() webhookData := map[string]interface{}{ "from": "test@example.com", "to": "recipient@example.com", @@ -151,195 +178,106 @@ func TestService_TriggerWebhook(t *testing.T) { "body": "Test message body", } - err = service.TriggerWebhook(ctx, "nonexistent", webhookData) + mockCtx := createMockGinContext(webhookData) + err = service.TriggerWebhook("nonexistent", mockCtx) assert.Error(t, err) assert.Contains(t, err.Error(), "provider not found") // Test with existing provider (if any are loaded) if len(providers) > 0 { - // Get the first provider name + // Find a provider that supports TriggerWebhook (not SMTP/mailer) var providerName string - for name := range providers { - providerName = name - break + var provider types.Provider + for name, p := range providers { + if p.GetType() != "mailer" { // Skip SMTP providers as they don't support TriggerWebhook + providerName = name + provider = p + break + } } - // Register a handler to capture the triggered message - var receivedMessage *types.Message - var mu sync.Mutex + if providerName != "" { + // Register a handler to capture the triggered message + var receivedMessage *types.Message + var mu sync.Mutex - handler := func(ctx context.Context, message *types.Message) error { - mu.Lock() - defer mu.Unlock() - receivedMessage = message - t.Logf("Webhook handler: Received message from %s with subject: %s", message.From, message.Subject) - return nil - } + handler := func(ctx context.Context, message *types.Message) error { + mu.Lock() + defer mu.Unlock() + receivedMessage = message + t.Logf("Webhook handler: Received message from %s with subject: %s", message.From, message.Subject) + return nil + } - err = service.OnReceive(handler) - assert.NoError(t, err) - - // Trigger webhook - err = service.TriggerWebhook(ctx, providerName, webhookData) - // Note: This might fail if the provider's Receive method has validation, - // but it should not panic and should attempt to trigger handlers - if err != nil { - t.Logf("TriggerWebhook returned error (may be expected): %v", err) - } - - // Give some time for async processing - time.Sleep(100 * time.Millisecond) - - // Check if handler was triggered - mu.Lock() - if receivedMessage != nil { - assert.Equal(t, "test@example.com", receivedMessage.From) - assert.Equal(t, "Test Subject", receivedMessage.Subject) - assert.Equal(t, "Test message body", receivedMessage.Body) - assert.Contains(t, receivedMessage.To, "recipient@example.com") - } - mu.Unlock() - } -} - -func TestService_ConvertWebhookToMessage(t *testing.T) { - // Prepare test environment - test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") - defer test.Clean() - - // Load real providers - providers, err := loadProviders() - require.NoError(t, err) - - // Create a test service - service := &Service{ - config: &types.Config{}, - providers: providers, - providersByType: make(map[types.MessageType][]types.Provider), - channels: make(map[string]types.Channel), - defaults: make(map[string]string), - receivers: make(map[string]context.CancelFunc), - messageHandlers: make([]types.MessageHandler, 0), - } - - tests := []struct { - name string - providerName string - data map[string]interface{} - expectedType types.MessageType - }{ - { - name: "Email webhook data", - providerName: "test-mailer", - data: map[string]interface{}{ - "type": "email", - "from": "sender@example.com", - "to": "recipient@example.com", - "subject": "Test Email", - "body": "Email body content", - "html": "
Email HTML content
", - }, - expectedType: types.MessageTypeEmail, - }, - { - name: "SMS webhook data", - providerName: "test-twilio", - data: map[string]interface{}{ - "type": "sms", - "from": "+1234567890", - "to": "+0987654321", - "body": "SMS message content", - "phone": "+0987654321", - }, - expectedType: types.MessageTypeSMS, - }, - { - name: "WhatsApp webhook data", - providerName: "test-twilio", - data: map[string]interface{}{ - "type": "whatsapp", - "from": "+1234567890", - "to": "+0987654321", - "body": "WhatsApp message content", - "whatsapp": "+0987654321", - }, - expectedType: types.MessageTypeWhatsApp, - }, - { - name: "Array recipients", - providerName: "test-mailer", - data: map[string]interface{}{ - "from": "sender@example.com", - "to": []string{"recipient1@example.com", "recipient2@example.com"}, - "subject": "Test Email", - "body": "Email body content", - }, - expectedType: types.MessageTypeEmail, - }, - { - name: "Interface array recipients", - providerName: "test-mailer", - data: map[string]interface{}{ - "from": "sender@example.com", - "to": []interface{}{"recipient1@example.com", "recipient2@example.com"}, - "subject": "Test Email", - "body": "Email body content", - }, - expectedType: types.MessageTypeEmail, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - message, err := service.convertWebhookToMessage(tt.providerName, tt.data) + err = service.OnReceive(handler) assert.NoError(t, err) - assert.NotNil(t, message) - // Check basic fields - if from, ok := tt.data["from"].(string); ok { - assert.Equal(t, from, message.From) - } - if subject, ok := tt.data["subject"].(string); ok { - assert.Equal(t, subject, message.Subject) - } - if body, ok := tt.data["body"].(string); ok { - assert.Equal(t, body, message.Body) - } - if html, ok := tt.data["html"].(string); ok { - assert.Equal(t, html, message.HTML) - } - - // Check recipients - if to, ok := tt.data["to"]; ok { - switch v := to.(type) { - case string: - assert.Contains(t, message.To, v) - case []string: - for _, recipient := range v { - assert.Contains(t, message.To, recipient) - } - case []interface{}: - for _, recipient := range v { - if str, ok := recipient.(string); ok { - assert.Contains(t, message.To, str) - } - } + // Create appropriate webhook data based on provider type + var mockCtx *gin.Context + switch provider.GetType() { + case "mailgun": + // Mailgun expects specific event fields + mailgunData := map[string]interface{}{ + "event": "delivered", + "recipient": "recipient@example.com", + "sender": "test@example.com", + "subject": "Test Subject", } + mockCtx = createMockGinContext(mailgunData) + case "twilio": + // Twilio expects SMS/WhatsApp fields + twilioData := map[string]interface{}{ + "MessageSid": "test-message-sid", + "SmsStatus": "received", + "From": "+1234567890", + "To": "+0987654321", + "Body": "Test message body", + } + mockCtx = createMockGinContext(twilioData) + default: + mockCtx = createMockGinContext(webhookData) } - // Check message type - if tt.data["type"] != nil { - assert.Equal(t, tt.expectedType, message.Type) + // Trigger webhook + err = service.TriggerWebhook(providerName, mockCtx) + // Note: This might fail if the provider's TriggerWebhook method has validation, + // but it should not panic and should attempt to trigger handlers + if err != nil { + t.Logf("TriggerWebhook returned error (may be expected): %v", err) } - // Check metadata - assert.NotNil(t, message.Metadata) - assert.Equal(t, tt.providerName, message.Metadata["provider"]) - assert.Equal(t, tt.data, message.Metadata["webhook_data"]) - }) + // Give some time for async processing + time.Sleep(100 * time.Millisecond) + + // Check if handler was triggered + mu.Lock() + if receivedMessage != nil { + assert.NotNil(t, receivedMessage) + assert.NotEmpty(t, receivedMessage.Subject) + t.Logf("Received message: From=%s, Subject=%s, Body=%s", receivedMessage.From, receivedMessage.Subject, receivedMessage.Body) + + // Verify provider-specific content + switch provider.GetType() { + case "mailgun": + assert.Contains(t, receivedMessage.Subject, "Email Delivered") + assert.Contains(t, receivedMessage.Body, "recipient@example.com") + case "twilio": + assert.Contains(t, receivedMessage.Subject, "Incoming Message") + assert.Contains(t, receivedMessage.Body, "Test message body") + } + } else { + t.Log("No message received - this may be expected for some provider configurations") + } + mu.Unlock() + } else { + t.Log("No providers support TriggerWebhook - skipping webhook test") + } } } +// Note: TestService_ConvertWebhookToMessage has been removed as the method is deprecated +// Webhook processing is now handled by provider-specific TriggerWebhook implementations + func TestService_TriggerOnReceiveHandlers_ErrorHandling(t *testing.T) { // Create a test service service := &Service{ @@ -430,37 +368,82 @@ func TestMessenger_OnReceiveIntegration(t *testing.T) { // Test TriggerWebhook with real providers (if any exist) if len(service.providers) > 0 { - // Get the first provider name + // Find a provider that supports TriggerWebhook (not SMTP/mailer) var providerName string - for name := range service.providers { - providerName = name - break + var provider types.Provider + for name, p := range service.providers { + if p.GetType() != "mailer" { // Skip SMTP providers as they don't support TriggerWebhook + providerName = name + provider = p + break + } } - webhookData := map[string]interface{}{ - "from": "integration@example.com", - "to": "test@example.com", - "subject": "Integration Test", - "body": "Integration test message", - } + if providerName != "" { + // Create appropriate webhook data based on provider type + var mockCtx *gin.Context + switch provider.GetType() { + case "mailgun": + // Mailgun expects specific event fields + mailgunData := map[string]interface{}{ + "event": "delivered", + "recipient": "test@example.com", + "sender": "integration@example.com", + "subject": "Integration Test", + } + mockCtx = createMockGinContext(mailgunData) + case "twilio": + // Twilio expects SMS/WhatsApp fields + twilioData := map[string]interface{}{ + "MessageSid": "integration-test-sid", + "SmsStatus": "received", + "From": "integration@example.com", + "To": "test@example.com", + "Body": "Integration test message", + } + mockCtx = createMockGinContext(twilioData) + default: + webhookData := map[string]interface{}{ + "from": "integration@example.com", + "to": "test@example.com", + "subject": "Integration Test", + "body": "Integration test message", + } + mockCtx = createMockGinContext(webhookData) + } - ctx := context.Background() - err = service.TriggerWebhook(ctx, providerName, webhookData) - // Error is acceptable as provider might reject test data - if err != nil { - t.Logf("TriggerWebhook returned error (may be expected): %v", err) - } + err = service.TriggerWebhook(providerName, mockCtx) + // Error is acceptable as provider might reject test data + if err != nil { + t.Logf("TriggerWebhook returned error (may be expected): %v", err) + } - // Give some time for processing - time.Sleep(100 * time.Millisecond) + // Give some time for processing + time.Sleep(100 * time.Millisecond) - // Check if handler was triggered - mu.Lock() - if receivedMessage != nil { - assert.Equal(t, "integration@example.com", receivedMessage.From) - assert.Equal(t, "Integration Test", receivedMessage.Subject) + // Check if handler was triggered + mu.Lock() + if receivedMessage != nil { + assert.NotNil(t, receivedMessage) + assert.NotEmpty(t, receivedMessage.Subject) + t.Logf("Integration test received message: From=%s, Subject=%s", receivedMessage.From, receivedMessage.Subject) + + // Verify provider-specific content + switch provider.GetType() { + case "mailgun": + assert.Contains(t, receivedMessage.Subject, "Email Delivered") + assert.Contains(t, receivedMessage.Body, "test@example.com") + case "twilio": + assert.Contains(t, receivedMessage.Subject, "Incoming Message") + assert.Equal(t, "integration@example.com", receivedMessage.From) + } + } else { + t.Log("No message received in integration test - this may be expected") + } + mu.Unlock() + } else { + t.Log("No providers support TriggerWebhook - skipping integration webhook test") } - mu.Unlock() } // Clean up - remove handler diff --git a/messenger/providers/mailer/mailer.go b/messenger/providers/mailer/mailer.go index 16227497..44d62f87 100644 --- a/messenger/providers/mailer/mailer.go +++ b/messenger/providers/mailer/mailer.go @@ -229,6 +229,11 @@ func (p *Provider) Validate() error { return nil } +// TriggerWebhook processes webhook requests - not supported for SMTP +func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) { + return nil, fmt.Errorf("TriggerWebhook not supported for SMTP/mailer provider") +} + // Close closes the provider connection (no-op for SMTP) func (p *Provider) Close() error { return nil diff --git a/messenger/providers/mailer/mailer_receive.go b/messenger/providers/mailer/mailer_receive.go index f049a934..e99119ab 100644 --- a/messenger/providers/mailer/mailer_receive.go +++ b/messenger/providers/mailer/mailer_receive.go @@ -29,35 +29,8 @@ type MailReceiver struct { lastCheckUID uint32 // Track last processed UID to avoid duplicates } -// Receive processes incoming messages/responses from mailer provider -func (p *Provider) Receive(ctx context.Context, data map[string]interface{}) error { - // Check if this provider supports receiving - if !p.SupportsReceiving() { - log.Printf("Mailer provider '%s' does not support receiving (IMAP not configured)", p.GetName()) - return fmt.Errorf("provider does not support receiving: IMAP not configured") - } - - // This method handles webhook-style data (for services like SendGrid, Mailgun) - // For direct IMAP email receiving, use StartMailReceiver - - // Parse common webhook data - if messageType, ok := data["type"].(string); ok { - switch messageType { - case "bounce": - return p.handleBounce(ctx, data) - case "delivery": - return p.handleDelivery(ctx, data) - case "complaint": - return p.handleComplaint(ctx, data) - default: - log.Printf("Mailer provider received unknown message type: %s", messageType) - } - } - - // For now, just log the received data - fmt.Printf("Mailer provider received data: %+v\n", data) - return nil -} +// Note: The Receive method has been removed as it's replaced by TriggerWebhook +// For direct IMAP email receiving, use StartMailReceiver // StartMailReceiver starts an IMAP-based email receiver with polling or IDLE support func (p *Provider) StartMailReceiver(ctx context.Context, handler func(*types.Message) error) error { @@ -433,31 +406,9 @@ func (r *MailReceiver) formatAddresses(addrs []*imap.Address) []string { return result } -// handleBounce processes email bounce notifications -func (p *Provider) handleBounce(ctx context.Context, data map[string]interface{}) error { - // TODO: Implement bounce handling logic - // - Update delivery status - // - Mark email as bounced - // - Potentially disable recipient - return nil -} - -// handleDelivery processes email delivery confirmations -func (p *Provider) handleDelivery(ctx context.Context, data map[string]interface{}) error { - // TODO: Implement delivery confirmation logic - // - Update delivery status - // - Log successful delivery - return nil -} - -// handleComplaint processes spam complaints -func (p *Provider) handleComplaint(ctx context.Context, data map[string]interface{}) error { - // TODO: Implement complaint handling logic - // - Mark sender as complained - // - Update reputation metrics - // - Potentially disable recipient - return nil -} +// Note: handleBounce, handleDelivery, and handleComplaint functions have been removed +// as they were only used by the deprecated Receive method. +// Webhook processing is now handled by TriggerWebhook method which is not implemented for SMTP providers. // extractMessageBody extracts plain text and HTML body from IMAP message func (r *MailReceiver) extractMessageBody(imapMsg *imap.Message) (plainText, htmlText string) { diff --git a/messenger/providers/mailer/mailer_receive_test.go b/messenger/providers/mailer/mailer_receive_test.go deleted file mode 100644 index 92af81d5..00000000 --- a/messenger/providers/mailer/mailer_receive_test.go +++ /dev/null @@ -1,919 +0,0 @@ -package mailer - -import ( - "context" - "os" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/yaoapp/yao/config" - "github.com/yaoapp/yao/messenger/types" - "github.com/yaoapp/yao/test" -) - -// Test helper functions for receive tests - -func getEnvOrDefaultReceive(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} - -func loadPrimaryTestConfigReceive(t *testing.T) types.ProviderConfig { - // Prepare test environment using YAO_TEST_APPLICATION which points to yao-dev-app - // Environment variables are already set in env.local.sh - test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") - defer test.Clean() - - // Create test config directly using environment variables for primary SMTP - // Port 465 requires SSL, port 587 requires TLS - smtpPort := os.Getenv("SMTP_PORT") - useSSL := smtpPort == "465" - useTLS := smtpPort == "587" || smtpPort == "25" - - config := types.ProviderConfig{ - Name: "primary", - Connector: "mailer", - Options: map[string]interface{}{ - "smtp": map[string]interface{}{ - "host": os.Getenv("SMTP_HOST"), - "port": os.Getenv("SMTP_PORT"), - "username": os.Getenv("SMTP_USERNAME"), - "password": os.Getenv("SMTP_PASSWORD"), - "from": os.Getenv("SMTP_FROM"), - "use_tls": useTLS, - "use_ssl": useSSL, - }, - }, - } - - return config -} - -func loadReliableTestConfigReceive(t *testing.T) types.ProviderConfig { - // Prepare test environment using YAO_TEST_APPLICATION which points to yao-dev-app - // Environment variables are already set in env.local.sh - test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") - defer test.Clean() - - // Create test config directly using environment variables for reliable SMTP - config := types.ProviderConfig{ - Name: "reliable", - Connector: "mailer", - Options: map[string]interface{}{ - "smtp": map[string]interface{}{ - "host": os.Getenv("RELIABLE_SMTP_HOST"), - "port": 587, // Hardcoded in reliable.mailer.yao - "username": os.Getenv("RELIABLE_SMTP_USERNAME"), - "password": os.Getenv("RELIABLE_SMTP_PASSWORD"), - "from": os.Getenv("RELIABLE_SMTP_FROM"), - "use_tls": true, - }, - "imap": map[string]interface{}{ - "host": getEnvOrDefaultReceive("RELIABLE_IMAP_HOST", os.Getenv("RELIABLE_SMTP_HOST")), - "port": getEnvOrDefaultReceive("RELIABLE_IMAP_PORT", "993"), - "username": getEnvOrDefaultReceive("RELIABLE_IMAP_USERNAME", os.Getenv("RELIABLE_SMTP_USERNAME")), - "password": getEnvOrDefaultReceive("RELIABLE_IMAP_PASSWORD", os.Getenv("RELIABLE_SMTP_PASSWORD")), - "use_ssl": true, - "mailbox": "INBOX", - }, - }, - } - - return config -} - -// Test IMAP Support Detection - -func TestSupportsReceiving_WithIMAP(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - // Reliable config has IMAP configured, should support receiving - assert.True(t, provider.SupportsReceiving()) -} - -func TestSupportsReceiving_WithoutIMAP(t *testing.T) { - config := loadPrimaryTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - // Primary config has no IMAP configured, should not support receiving - assert.False(t, provider.SupportsReceiving()) -} - -// Test Receive Method - -func TestReceive_WithoutIMAPSupport(t *testing.T) { - config := loadPrimaryTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - ctx := context.Background() - data := map[string]interface{}{ - "type": "delivery", - "message": "test message", - } - - // Should return error since IMAP is not configured - err = provider.Receive(ctx, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "provider does not support receiving: IMAP not configured") -} - -func TestReceive_WithIMAPSupport_Bounce(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - ctx := context.Background() - data := map[string]interface{}{ - "type": "bounce", - "email": "test@example.com", - "reason": "mailbox_full", - "timestamp": time.Now().Unix(), - "message_id": "test-message-123", - } - - // Should process bounce without error - err = provider.Receive(ctx, data) - assert.NoError(t, err) -} - -func TestReceive_WithIMAPSupport_Delivery(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - ctx := context.Background() - data := map[string]interface{}{ - "type": "delivery", - "email": "test@example.com", - "timestamp": time.Now().Unix(), - "message_id": "test-message-123", - } - - // Should process delivery without error - err = provider.Receive(ctx, data) - assert.NoError(t, err) -} - -func TestReceive_WithIMAPSupport_Complaint(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - ctx := context.Background() - data := map[string]interface{}{ - "type": "complaint", - "email": "test@example.com", - "reason": "spam", - "timestamp": time.Now().Unix(), - "message_id": "test-message-123", - } - - // Should process complaint without error - err = provider.Receive(ctx, data) - assert.NoError(t, err) -} - -func TestReceive_WithIMAPSupport_UnknownType(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - ctx := context.Background() - data := map[string]interface{}{ - "type": "unknown_event", - "message": "test message", - } - - // Should process unknown type without error (just logs) - err = provider.Receive(ctx, data) - assert.NoError(t, err) -} - -func TestReceive_WithIMAPSupport_NoType(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - ctx := context.Background() - data := map[string]interface{}{ - "message": "test message without type", - "data": "some data", - } - - // Should process data without type field without error - err = provider.Receive(ctx, data) - assert.NoError(t, err) -} - -// Test StartMailReceiver Method - -func TestStartMailReceiver_WithoutIMAPSupport(t *testing.T) { - config := loadPrimaryTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - ctx := context.Background() - handler := func(msg *types.Message) error { - return nil - } - - // Should return error since IMAP is not configured - err = provider.StartMailReceiver(ctx, handler) - assert.Error(t, err) - assert.Contains(t, err.Error(), "provider does not support receiving: IMAP not configured") -} - -func TestStartMailReceiver_WithIMAPSupport_InvalidConfig(t *testing.T) { - // Skip this test if IMAP credentials are not configured - if os.Getenv("RELIABLE_IMAP_HOST") == "" { - t.Skip("RELIABLE_IMAP_HOST not configured, skipping IMAP connection test") - } - - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - // Use a short timeout context - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - messageReceived := false - handler := func(msg *types.Message) error { - messageReceived = true - t.Logf("Received message: Subject=%s, From=%s", msg.Subject, msg.From) - return nil - } - - // This will likely fail due to invalid credentials, but should not panic - err = provider.StartMailReceiver(ctx, handler) - - // We expect this to fail in test environment, but it should be a connection error - if err != nil { - t.Logf("StartMailReceiver failed as expected in test environment: %v", err) - assert.Contains(t, err.Error(), "provider does not support receiving: IMAP not configured") - } else { - t.Log("StartMailReceiver started successfully") - // Wait a bit to see if any messages are received - time.Sleep(2 * time.Second) - t.Logf("Message received: %v", messageReceived) - } -} - -// Test MailReceiver Internal Methods - -func TestMailReceiver_TimeStampFiltering(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - // Create a mail receiver - receiver := &MailReceiver{ - provider: provider, - stopChan: make(chan bool), - startTime: time.Now(), - lastCheckUID: 0, - msgHandler: func(msg *types.Message) error { - return nil - }, - } - - // Test that start time is set correctly - assert.True(t, receiver.startTime.Before(time.Now().Add(time.Second))) - assert.True(t, receiver.startTime.After(time.Now().Add(-time.Second))) - assert.Equal(t, uint32(0), receiver.lastCheckUID) -} - -func TestMailReceiver_Stop(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - // Create a mail receiver - receiver := &MailReceiver{ - provider: provider, - stopChan: make(chan bool), - startTime: time.Now(), - lastCheckUID: 0, - msgHandler: func(msg *types.Message) error { - return nil - }, - } - - // Test stop functionality - go func() { - time.Sleep(100 * time.Millisecond) - receiver.Stop() - }() - - // This should not block indefinitely - select { - case <-receiver.stopChan: - t.Log("Stop signal received successfully") - case <-time.After(1 * time.Second): - t.Error("Stop signal not received within timeout") - } -} - -// Test Message Processing - -func TestMailReceiver_FormatAddress(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - receiver := &MailReceiver{ - provider: provider, - } - - // Test with empty addresses - result := receiver.formatAddress(nil) - assert.Equal(t, "", result) - - // Note: We can't easily test with real imap.Address without importing go-imap - // and creating mock addresses, but the function is tested through integration tests -} - -func TestMailReceiver_FormatAddresses(t *testing.T) { - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - receiver := &MailReceiver{ - provider: provider, - } - - // Test with empty addresses - result := receiver.formatAddresses(nil) - assert.Equal(t, []string{}, result) - - // Note: We can't easily test with real imap.Address without importing go-imap - // and creating mock addresses, but the function is tested through integration tests -} - -// Integration Tests - Real Email Send and Receive - -func TestRealEmailSendAndReceive_Integration(t *testing.T) { - // Skip this test if IMAP credentials are not configured - if os.Getenv("RELIABLE_IMAP_HOST") == "" || os.Getenv("RELIABLE_SMTP_HOST") == "" { - t.Skip("RELIABLE_IMAP_HOST or RELIABLE_SMTP_HOST not configured, skipping integration test") - } - - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - // Verify provider supports both sending and receiving - require.True(t, provider.SupportsReceiving(), "Provider must support receiving for this test") - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - // Channel to receive the email - emailReceived := make(chan *types.Message, 1) - var receivedEmail *types.Message - - // Start mail receiver with detailed logging - go func() { - t.Log("Starting mail receiver goroutine...") - err := provider.StartMailReceiver(ctx, func(msg *types.Message) error { - t.Logf("=== EMAIL RECEIVED ===") - t.Logf("Subject: %s", msg.Subject) - t.Logf("From: %s", msg.From) - t.Logf("To: %v", msg.To) - t.Logf("Type: %s", msg.Type) - if msg.Body != "" { - bodyPreview := msg.Body - if len(bodyPreview) > 200 { - bodyPreview = bodyPreview[:200] + "..." - } - t.Logf("Body: %s", bodyPreview) - } - if msg.HTML != "" { - htmlPreview := msg.HTML - if len(htmlPreview) > 100 { - htmlPreview = htmlPreview[:100] + "..." - } - t.Logf("HTML: %s", htmlPreview) - } - if msg.Metadata != nil { - t.Logf("Metadata: %+v", msg.Metadata) - } - t.Logf("=== END EMAIL ===") - - // Check if this is our test email - if msg.Subject != "" && msg.Body != "" { - select { - case emailReceived <- msg: - t.Log("✅ Test email captured successfully") - default: - t.Log("⚠️ Email channel full, skipping") - } - } - return nil - }) - - if err != nil { - t.Logf("❌ Mail receiver stopped with error: %v", err) - } else { - t.Log("✅ Mail receiver stopped gracefully") - } - }() - - // Give receiver time to start and connect - t.Log("⏳ Waiting 5 seconds for mail receiver to start and connect...") - time.Sleep(5 * time.Second) - t.Log("✅ Mail receiver should be connected now") - - // Create and send test email - testSubject := "Integration Test Email - " + time.Now().Format("2006-01-02 15:04:05") - testBody := "This is an integration test email sent at " + time.Now().Format("2006-01-02 15:04:05") + ". If you receive this, the send/receive cycle is working!" - - // Get the 'from' address from config to send email to ourselves - smtpConfig := config.Options["smtp"].(map[string]interface{}) - fromAddressRaw := smtpConfig["from"].(string) - - // Extract just the email address from "Name" + testBody + "
", - Headers: map[string]string{ - "X-Test-Type": "integration-test", - "X-Test-ID": time.Now().Format("20060102150405"), - }, - } - - t.Logf("📧 Sending test email to: %s", fromAddress) - t.Logf("📧 Subject: %s", testSubject) - t.Logf("📧 Body: %s", testBody) - - // Send the email - sendErr := provider.Send(ctx, testMessage) - if sendErr != nil { - t.Logf("❌ Failed to send test email: %v", sendErr) - // Don't fail the test immediately, as this might be expected in some environments - t.Skip("Could not send test email, skipping integration test") - } - - t.Log("✅ Test email sent successfully, waiting for receipt...") - t.Log("⏳ Monitoring for incoming emails (timeout: 90 seconds)...") - - // Wait for email to be received - select { - case receivedEmail = <-emailReceived: - t.Log("SUCCESS: Email send/receive cycle completed!") - - // Cancel the context to stop the mail receiver gracefully - cancel() - t.Log("🛑 Gracefully stopping mail receiver...") - - // Give some time for graceful shutdown - time.Sleep(1 * time.Second) - - // Verify the received email - assert.NotNil(t, receivedEmail) - assert.Equal(t, types.MessageTypeEmail, receivedEmail.Type) - assert.NotEmpty(t, receivedEmail.Subject) - assert.NotEmpty(t, receivedEmail.From) - - // Check if it's our test email (subject should contain our test string) - if receivedEmail.Subject == testSubject { - t.Log("PERFECT MATCH: Received the exact email we sent!") - assert.Equal(t, testSubject, receivedEmail.Subject) - // Note: Body might be modified by email processing, so we check if it contains our content - if receivedEmail.Body != "" { - t.Logf("Received body: %s", receivedEmail.Body) - } - } else { - t.Logf("Received different email: Subject='%s'", receivedEmail.Subject) - t.Log("This might be another email in the inbox, which is also a valid test result") - } - - // Verify metadata - assert.NotNil(t, receivedEmail.Metadata) - if receivedEmail.Metadata != nil { - t.Logf("Email metadata: %+v", receivedEmail.Metadata) - } - - t.Log("✅ Test completed successfully - mail receiver stopped gracefully") - return // Exit the test successfully - - case <-time.After(90 * time.Second): - t.Log("TIMEOUT: No email received within 90 seconds") - t.Log("This might be expected in test environments with:") - t.Log("- Email delivery delays") - t.Log("- IMAP connection issues") - t.Log("- Firewall restrictions") - t.Log("- Invalid credentials") - - // Cancel context for graceful shutdown - cancel() - t.Log("🛑 Stopping mail receiver due to timeout...") - time.Sleep(1 * time.Second) - - // This is not necessarily a failure - email delivery can be delayed - t.Skip("Email not received within timeout - this may be expected in test environment") - - case <-ctx.Done(): - t.Log("Context cancelled during email wait") - t.Skip("Test context cancelled") - } -} - -func TestRealEmailReceiveOnly_Integration(t *testing.T) { - // Skip this test if IMAP credentials are not configured - if os.Getenv("RELIABLE_IMAP_HOST") == "" { - t.Skip("RELIABLE_IMAP_HOST not configured, skipping IMAP receive test") - } - - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - // Verify provider supports receiving - require.True(t, provider.SupportsReceiving(), "Provider must support receiving for this test") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - emailCount := 0 - maxEmailsToProcess := 5 // Limit the number of emails to process for testing - - t.Log("Starting mail receiver to check for existing emails...") - - // Start mail receiver to see if there are any emails - err = provider.StartMailReceiver(ctx, func(msg *types.Message) error { - emailCount++ - t.Logf("Email #%d received:", emailCount) - t.Logf(" Subject: %s", msg.Subject) - t.Logf(" From: %s", msg.From) - t.Logf(" To: %v", msg.To) - if msg.Body != "" { - bodyPreview := msg.Body - if len(bodyPreview) > 100 { - bodyPreview = bodyPreview[:100] + "..." - } - t.Logf(" Body preview: %s", bodyPreview) - } - if msg.HTML != "" { - htmlPreview := msg.HTML - if len(htmlPreview) > 100 { - htmlPreview = htmlPreview[:100] + "..." - } - t.Logf(" HTML preview: %s", htmlPreview) - } - if msg.Metadata != nil { - t.Logf(" Metadata: %+v", msg.Metadata) - } - - // Stop after processing a few emails to avoid long-running tests - if emailCount >= maxEmailsToProcess { - t.Logf("Processed %d emails, stopping receiver for test completion", emailCount) - cancel() // Trigger graceful shutdown - } - - return nil - }) - - if err != nil { - t.Logf("Mail receiver ended: %v", err) - - // Check if it's a connection error (expected in many test environments) - if strings.Contains(err.Error(), "failed to connect") || - strings.Contains(err.Error(), "authentication failed") || - strings.Contains(err.Error(), "connection refused") { - t.Skip("IMAP connection failed - this is expected in test environments without proper email server access") - } - - // Other errors might indicate real issues - t.Errorf("Unexpected error from mail receiver: %v", err) - } - - t.Logf("Mail receiver test completed. Total emails processed: %d", emailCount) - - if emailCount > 0 { - t.Log("SUCCESS: Mail receiver is working and processed emails from the mailbox") - } else { - t.Log("No emails received - this could mean:") - t.Log("- Mailbox is empty (normal)") - t.Log("- IMAP connection issues") - t.Log("- Time-based filtering working (only new emails)") - } -} - -func TestManualEmailReceive_Integration(t *testing.T) { - // Skip this test if IMAP credentials are not configured - if os.Getenv("RELIABLE_IMAP_HOST") == "" { - t.Skip("RELIABLE_IMAP_HOST not configured, skipping manual receive test") - } - - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - // Verify provider supports receiving - require.True(t, provider.SupportsReceiving(), "Provider must support receiving for this test") - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - emailReceived := make(chan *types.Message, 5) - testCompleted := make(chan bool, 1) - - t.Log("🔍 MANUAL TEST: Please send an email to shadow.iqka@gmail.com now!") - t.Log("📧 Subject should contain 'MANUAL TEST' for easy identification") - t.Log("⏰ You have 60 seconds to send the email...") - - // Start mail receiver - go func() { - err := provider.StartMailReceiver(ctx, func(msg *types.Message) error { - t.Logf("📧 RECEIVED EMAIL:") - t.Logf(" Subject: %s", msg.Subject) - t.Logf(" From: %s", msg.From) - t.Logf(" To: %v", msg.To) - t.Logf(" Type: %s", msg.Type) - - // Send to channel for verification - select { - case emailReceived <- msg: - t.Log("✅ Email captured successfully!") - default: - t.Log("⚠️ Email channel full") - } - return nil - }) - - if err != nil { - t.Logf("Mail receiver ended: %v", err) - } - testCompleted <- true - }() - - // Wait for emails or timeout - emailCount := 0 - timeout := time.After(60 * time.Second) - - for { - select { - case receivedEmail := <-emailReceived: - emailCount++ - t.Logf("🎉 EMAIL #%d RECEIVED!", emailCount) - t.Logf("Subject: %s", receivedEmail.Subject) - t.Logf("From: %s", receivedEmail.From) - - // Check if this looks like a manual test email - if strings.Contains(strings.ToUpper(receivedEmail.Subject), "MANUAL TEST") { - t.Log("🎯 MANUAL TEST EMAIL DETECTED!") - cancel() - <-testCompleted - - assert.NotNil(t, receivedEmail) - assert.NotEmpty(t, receivedEmail.Subject) - assert.NotEmpty(t, receivedEmail.From) - - t.Log("✅ MANUAL TEST PASSED - Email receiving works!") - return - } - - // Continue waiting for more emails - t.Log("📬 Waiting for more emails...") - - case <-timeout: - t.Logf("⏰ Manual test timeout after 60 seconds") - t.Logf("📊 Total emails received: %d", emailCount) - cancel() - <-testCompleted - - if emailCount > 0 { - t.Log("✅ Email receiving is working (received emails during test)") - } else { - t.Log("❓ No emails received - this could mean:") - t.Log(" - No emails were sent during the test") - t.Log(" - Email delivery is delayed") - t.Log(" - IMAP filtering is working (only new emails)") - } - return - - case <-ctx.Done(): - <-testCompleted - t.Logf("📊 Test ended. Total emails received: %d", emailCount) - return - } - } -} - -func TestQuickEmailSendAndReceive_Integration(t *testing.T) { - // Skip this test if IMAP credentials are not configured - if os.Getenv("RELIABLE_IMAP_HOST") == "" || os.Getenv("RELIABLE_SMTP_HOST") == "" { - t.Skip("RELIABLE_IMAP_HOST or RELIABLE_SMTP_HOST not configured, skipping quick integration test") - } - - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - require.NoError(t, err) - - // Verify provider supports both sending and receiving - require.True(t, provider.SupportsReceiving(), "Provider must support receiving for this test") - - // Use longer timeout to account for email delivery delays - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - // Channel to receive the email - emailReceived := make(chan *types.Message, 5) - testCompleted := make(chan bool, 1) - - var sentTestSubject string - emailCount := 0 - - // Start mail receiver - go func() { - t.Log("🚀 Starting mail receiver for send/receive test...") - err := provider.StartMailReceiver(ctx, func(msg *types.Message) error { - emailCount++ - t.Logf("📧 Email #%d received: Subject='%s', From='%s'", emailCount, msg.Subject, msg.From) - - // Send all received emails to the channel for analysis - select { - case emailReceived <- msg: - t.Logf("✅ Email #%d captured for analysis", emailCount) - default: - t.Log("⚠️ Email channel full") - } - return nil - }) - - if err != nil { - t.Logf("Mail receiver ended: %v", err) - } - testCompleted <- true - }() - - // Give receiver more time to start and connect - t.Log("⏳ Waiting 5 seconds for mail receiver to fully start...") - time.Sleep(5 * time.Second) - - // Create and send test email with unique identifier - timestamp := time.Now().Format("15:04:05.000") - testSubject := "AUTOMATED TEST EMAIL - " + timestamp - testBody := "This is an automated integration test email sent at " + timestamp + ". Please ignore this message." - sentTestSubject = testSubject // Store for comparison - - // Get the 'from' address from config - smtpConfig := config.Options["smtp"].(map[string]interface{}) - fromAddressRaw := smtpConfig["from"].(string) - - // Extract just the email address - fromAddress := fromAddressRaw - if strings.Contains(fromAddressRaw, "<") && strings.Contains(fromAddressRaw, ">") { - start := strings.Index(fromAddressRaw, "<") - end := strings.Index(fromAddressRaw, ">") - if start >= 0 && end > start { - fromAddress = fromAddressRaw[start+1 : end] - } - } - - testMessage := &types.Message{ - Type: types.MessageTypeEmail, - To: []string{fromAddress}, - Subject: testSubject, - Body: testBody, - Headers: map[string]string{ - "X-Test-Type": "automated-integration-test", - "X-Test-Timestamp": timestamp, - }, - } - - t.Logf("📤 Sending test email: %s", testSubject) - t.Logf("📧 To: %s", fromAddress) - - // Send the email - sendErr := provider.Send(ctx, testMessage) - if sendErr != nil { - t.Logf("❌ Failed to send test email: %v", sendErr) - cancel() // Stop receiver - <-testCompleted - t.Skip("Could not send test email, skipping integration test") - } - - t.Log("✅ Test email sent successfully!") - t.Log("⏳ Monitoring for incoming emails (timeout: 100 seconds)...") - t.Log("📊 Will analyze all received emails to find our test email...") - - // Wait for emails and analyze them - foundTestEmail := false - timeout := time.After(100 * time.Second) - - for !foundTestEmail { - select { - case receivedEmail := <-emailReceived: - t.Logf("📧 Analyzing email: Subject='%s'", receivedEmail.Subject) - - // Check if this is our test email - if receivedEmail.Subject == sentTestSubject { - t.Log("🎯 FOUND OUR TEST EMAIL!") - t.Logf("✅ Subject matches: %s", receivedEmail.Subject) - t.Logf("✅ From: %s", receivedEmail.From) - - // Stop the receiver gracefully - cancel() - <-testCompleted - - // Verify the email properties - assert.NotNil(t, receivedEmail) - assert.Equal(t, sentTestSubject, receivedEmail.Subject) - assert.NotEmpty(t, receivedEmail.From) - assert.Equal(t, types.MessageTypeEmail, receivedEmail.Type) - - t.Log("🎉 INTEGRATION TEST PASSED - Email send/receive cycle works!") - return - } else if strings.Contains(receivedEmail.Subject, "AUTOMATED TEST") { - t.Log("🔍 Found another automated test email (different timestamp)") - } else { - t.Log("📬 Found other email, continuing to monitor...") - } - - case <-timeout: - t.Logf("⏰ Test timeout after 100 seconds") - t.Logf("📊 Total emails received during test: %d", emailCount) - t.Logf("🔍 Looking for subject: %s", sentTestSubject) - - cancel() - <-testCompleted - - if emailCount > 0 { - t.Log("✅ Email receiving is working (got emails), but our test email may be delayed") - t.Log("💡 This could be due to Gmail's email processing delays") - } else { - t.Log("❓ No emails received during test period") - t.Log("💡 This could indicate IMAP filtering is working correctly (only new emails)") - } - - t.Skip("Test email not received within timeout - email delivery delays are common") - - case <-ctx.Done(): - <-testCompleted - t.Logf("📊 Test ended. Total emails received: %d", emailCount) - t.Skip("Test context cancelled") - } - } -} - -// Benchmark Tests for Receive Functionality - -func BenchmarkReceive_WithIMAPSupport(b *testing.B) { - t := &testing.T{} - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - if err != nil { - b.Fatal(err) - } - - ctx := context.Background() - data := map[string]interface{}{ - "type": "delivery", - "message": "benchmark test message", - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - err := provider.Receive(ctx, data) - if err != nil { - b.Fatal(err) - } - } -} - -func BenchmarkSupportsReceiving(b *testing.B) { - t := &testing.T{} - config := loadReliableTestConfigReceive(t) - provider, err := NewMailerProvider(config) - if err != nil { - b.Fatal(err) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = provider.SupportsReceiving() - } -} diff --git a/messenger/providers/mailgun/mailgun_receive.go b/messenger/providers/mailgun/mailgun_receive.go index e8fe81cf..a37b00a7 100644 --- a/messenger/providers/mailgun/mailgun_receive.go +++ b/messenger/providers/mailgun/mailgun_receive.go @@ -1,22 +1,91 @@ package mailgun import ( - "context" "fmt" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/messenger/types" ) -// Receive processes incoming messages/responses from Mailgun -func (p *Provider) Receive(ctx context.Context, data map[string]interface{}) error { - // TODO: Implement Mailgun webhook message processing - // This will handle: - // - Email delivery events - // - Email bounce events - // - Email complaint events - // - Email click/open tracking events - // - Incoming email messages +// TriggerWebhook processes Mailgun webhook requests and converts to Message +func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) { + // Cast to gin.Context + ginCtx, ok := c.(*gin.Context) + if !ok { + return nil, fmt.Errorf("expected *gin.Context, got %T", c) + } - // For now, just log the received data - fmt.Printf("Mailgun provider received data: %+v\n", data) + // Parse form data (Mailgun sends application/x-www-form-urlencoded) + if err := ginCtx.Request.ParseForm(); err != nil { + return nil, fmt.Errorf("failed to parse form data: %w", err) + } - return nil + // Create message from Mailgun webhook data + message := &types.Message{ + Metadata: make(map[string]interface{}), + } + + // Extract common Mailgun webhook fields + event := ginCtx.Request.FormValue("event") + recipient := ginCtx.Request.FormValue("recipient") + messageId := ginCtx.Request.FormValue("message-id") + timestamp := ginCtx.Request.FormValue("timestamp") + token := ginCtx.Request.FormValue("token") + signature := ginCtx.Request.FormValue("signature") + + // Map to standard message format + message.Type = types.MessageTypeEmail + if recipient != "" { + message.To = []string{recipient} + } + if messageId != "" { + message.Metadata["message_id"] = messageId + } + + // Store webhook-specific data + message.Metadata["provider"] = "mailgun" + message.Metadata["event"] = event + message.Metadata["timestamp"] = timestamp + message.Metadata["token"] = token + message.Metadata["signature"] = signature + message.Metadata["webhook_data"] = ginCtx.Request.Form + + // Handle different event types + switch event { + case "delivered": + message.Subject = "Email Delivered" + message.Body = fmt.Sprintf("Email to %s was delivered successfully", recipient) + case "failed": + message.Subject = "Email Failed" + message.Body = fmt.Sprintf("Email to %s failed to deliver", recipient) + if reason := ginCtx.Request.FormValue("reason"); reason != "" { + message.Body += ": " + reason + } + case "opened": + message.Subject = "Email Opened" + message.Body = fmt.Sprintf("Email to %s was opened", recipient) + case "clicked": + message.Subject = "Email Clicked" + message.Body = fmt.Sprintf("Link in email to %s was clicked", recipient) + case "unsubscribed": + message.Subject = "Email Unsubscribed" + message.Body = fmt.Sprintf("Recipient %s unsubscribed", recipient) + case "complained": + message.Subject = "Email Complained" + message.Body = fmt.Sprintf("Recipient %s marked email as spam", recipient) + case "stored": + // Incoming email + message.Subject = ginCtx.Request.FormValue("subject") + message.Body = ginCtx.Request.FormValue("body-plain") + message.HTML = ginCtx.Request.FormValue("body-html") + message.From = ginCtx.Request.FormValue("sender") + if message.Subject == "" { + message.Subject = "Incoming Email" + } + default: + message.Subject = "Mailgun Webhook Event" + message.Body = fmt.Sprintf("Received %s event for %s", event, recipient) + } + + return message, nil } diff --git a/messenger/providers/mailgun/mailgun_receive_test.go b/messenger/providers/mailgun/mailgun_receive_test.go new file mode 100644 index 00000000..380608f2 --- /dev/null +++ b/messenger/providers/mailgun/mailgun_receive_test.go @@ -0,0 +1,171 @@ +package mailgun + +import ( + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/messenger/types" +) + +// createMockGinContext creates a mock gin.Context for testing webhook functionality +func createMockGinContext(formData map[string]interface{}) *gin.Context { + // Create form values + values := url.Values{} + for key, value := range formData { + if str, ok := value.(string); ok { + values.Set(key, str) + } + } + + // Create request with form data + req := httptest.NewRequest("POST", "/webhook/mailgun", strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Create response recorder + w := httptest.NewRecorder() + + // Create gin context + c, _ := gin.CreateTestContext(w) + c.Request = req + + return c +} + +func TestProvider_TriggerWebhook(t *testing.T) { + // Create a mailgun provider + config := types.ProviderConfig{ + Name: "test-mailgun", + Connector: "mailgun", + Options: map[string]interface{}{ + "domain": "test.mailgun.org", + "api_key": "test-api-key", + "from": "test@test.mailgun.org", + }, + } + + provider, err := NewMailgunProvider(config) + require.NoError(t, err) + + tests := []struct { + name string + formData map[string]interface{} + wantErr bool + checkFn func(t *testing.T, msg *types.Message) + }{ + { + name: "delivered event", + formData: map[string]interface{}{ + "event": "delivered", + "recipient": "test@example.com", + "message-id": "test-message-id", + "timestamp": "1234567890", + }, + wantErr: false, + checkFn: func(t *testing.T, msg *types.Message) { + assert.Equal(t, types.MessageTypeEmail, msg.Type) + assert.Contains(t, msg.To, "test@example.com") + assert.Equal(t, "Email Delivered", msg.Subject) + assert.Contains(t, msg.Body, "test@example.com") + assert.Contains(t, msg.Body, "delivered successfully") + assert.Equal(t, "mailgun", msg.Metadata["provider"]) + assert.Equal(t, "delivered", msg.Metadata["event"]) + }, + }, + { + name: "failed event", + formData: map[string]interface{}{ + "event": "failed", + "recipient": "failed@example.com", + "reason": "bounce", + }, + wantErr: false, + checkFn: func(t *testing.T, msg *types.Message) { + assert.Equal(t, "Email Failed", msg.Subject) + assert.Contains(t, msg.Body, "failed@example.com") + assert.Contains(t, msg.Body, "bounce") + }, + }, + { + name: "stored event (incoming email)", + formData: map[string]interface{}{ + "event": "stored", + "sender": "sender@example.com", + "recipient": "inbox@example.com", + "subject": "Incoming Email Subject", + "body-plain": "Email body content", + "body-html": "Email HTML content
", + }, + wantErr: false, + checkFn: func(t *testing.T, msg *types.Message) { + assert.Equal(t, "Incoming Email Subject", msg.Subject) + assert.Equal(t, "sender@example.com", msg.From) + assert.Equal(t, "Email body content", msg.Body) + assert.Equal(t, "Email HTML content
", msg.HTML) + }, + }, + { + name: "opened event", + formData: map[string]interface{}{ + "event": "opened", + "recipient": "reader@example.com", + }, + wantErr: false, + checkFn: func(t *testing.T, msg *types.Message) { + assert.Equal(t, "Email Opened", msg.Subject) + assert.Contains(t, msg.Body, "reader@example.com") + assert.Contains(t, msg.Body, "opened") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtx := createMockGinContext(tt.formData) + + msg, err := provider.TriggerWebhook(mockCtx) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, msg) + + // Run specific checks + if tt.checkFn != nil { + tt.checkFn(t, msg) + } + + // Common checks + assert.NotNil(t, msg.Metadata) + assert.Equal(t, "mailgun", msg.Metadata["provider"]) + }) + } +} + +func TestProvider_TriggerWebhook_InvalidInput(t *testing.T) { + config := types.ProviderConfig{ + Name: "test-mailgun", + Connector: "mailgun", + Options: map[string]interface{}{ + "domain": "test.mailgun.org", + "api_key": "test-api-key", + "from": "test@test.mailgun.org", + }, + } + + provider, err := NewMailgunProvider(config) + require.NoError(t, err) + + // Test with wrong input type + msg, err := provider.TriggerWebhook("not-gin-context") + assert.Error(t, err) + assert.Nil(t, msg) + assert.Contains(t, err.Error(), "expected *gin.Context") +} diff --git a/messenger/providers/twilio/twilio_receive.go b/messenger/providers/twilio/twilio_receive.go index 36cff490..e96265d7 100644 --- a/messenger/providers/twilio/twilio_receive.go +++ b/messenger/providers/twilio/twilio_receive.go @@ -1,22 +1,99 @@ package twilio import ( - "context" "fmt" + "strings" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/messenger/types" ) -// Receive processes incoming messages/responses from Twilio -func (p *Provider) Receive(ctx context.Context, data map[string]interface{}) error { - // TODO: Implement Twilio webhook message processing - // This will handle: - // - SMS delivery status callbacks - // - Incoming SMS messages - // - WhatsApp message status updates - // - WhatsApp incoming messages - // - Email delivery events (SendGrid webhooks) +// TriggerWebhook processes Twilio webhook requests and converts to Message +func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) { + // Cast to gin.Context + ginCtx, ok := c.(*gin.Context) + if !ok { + return nil, fmt.Errorf("expected *gin.Context, got %T", c) + } - // For now, just log the received data - fmt.Printf("Twilio provider received data: %+v\n", data) + // Parse form data (Twilio sends application/x-www-form-urlencoded) + if err := ginCtx.Request.ParseForm(); err != nil { + return nil, fmt.Errorf("failed to parse form data: %w", err) + } - return nil + // Create message from Twilio webhook data + message := &types.Message{ + Metadata: make(map[string]interface{}), + } + + // Extract common Twilio webhook fields + messageSid := ginCtx.Request.FormValue("MessageSid") + smsStatus := ginCtx.Request.FormValue("SmsStatus") + from := ginCtx.Request.FormValue("From") + to := ginCtx.Request.FormValue("To") + body := ginCtx.Request.FormValue("Body") + numSegments := ginCtx.Request.FormValue("NumSegments") + errorCode := ginCtx.Request.FormValue("ErrorCode") + + // Map to standard message format + if from != "" { + message.From = from + } + if to != "" { + message.To = []string{to} + } + if body != "" { + message.Body = body + } + + // Determine message type based on phone number format + if strings.HasPrefix(to, "whatsapp:") || strings.HasPrefix(from, "whatsapp:") { + message.Type = types.MessageTypeWhatsApp + } else { + message.Type = types.MessageTypeSMS + } + + // Store webhook-specific data + message.Metadata["provider"] = "twilio" + message.Metadata["message_sid"] = messageSid + message.Metadata["sms_status"] = smsStatus + message.Metadata["num_segments"] = numSegments + message.Metadata["error_code"] = errorCode + message.Metadata["webhook_data"] = ginCtx.Request.Form + + // Handle different status types + switch smsStatus { + case "queued": + message.Subject = "Message Queued" + message.Body = fmt.Sprintf("Message from %s to %s is queued for delivery", from, to) + case "sent": + message.Subject = "Message Sent" + message.Body = fmt.Sprintf("Message from %s to %s was sent", from, to) + case "received": + // Incoming message + message.Subject = "Incoming Message" + if message.Body == "" { + message.Body = "Received message from " + from + } + case "delivered": + message.Subject = "Message Delivered" + message.Body = fmt.Sprintf("Message from %s to %s was delivered", from, to) + case "undelivered": + message.Subject = "Message Undelivered" + message.Body = fmt.Sprintf("Message from %s to %s was not delivered", from, to) + if errorCode != "" { + message.Body += " (Error: " + errorCode + ")" + } + case "failed": + message.Subject = "Message Failed" + message.Body = fmt.Sprintf("Message from %s to %s failed", from, to) + if errorCode != "" { + message.Body += " (Error: " + errorCode + ")" + } + default: + message.Subject = "Twilio Webhook Event" + message.Body = fmt.Sprintf("Received %s status for message from %s to %s", smsStatus, from, to) + } + + return message, nil } diff --git a/messenger/providers/twilio/twilio_receive_test.go b/messenger/providers/twilio/twilio_receive_test.go new file mode 100644 index 00000000..f1354b78 --- /dev/null +++ b/messenger/providers/twilio/twilio_receive_test.go @@ -0,0 +1,193 @@ +package twilio + +import ( + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/messenger/types" +) + +// createMockGinContext creates a mock gin.Context for testing webhook functionality +func createMockGinContext(formData map[string]interface{}) *gin.Context { + // Create form values + values := url.Values{} + for key, value := range formData { + if str, ok := value.(string); ok { + values.Set(key, str) + } + } + + // Create request with form data + req := httptest.NewRequest("POST", "/webhook/twilio", strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Create response recorder + w := httptest.NewRecorder() + + // Create gin context + c, _ := gin.CreateTestContext(w) + c.Request = req + + return c +} + +func TestProvider_TriggerWebhook(t *testing.T) { + // Create a twilio provider + config := types.ProviderConfig{ + Name: "test-twilio", + Connector: "twilio", + Options: map[string]interface{}{ + "account_sid": "test-account-sid", + "auth_token": "test-auth-token", + "from_phone": "+1234567890", + }, + } + + provider, err := NewTwilioProvider(config) + require.NoError(t, err) + + tests := []struct { + name string + formData map[string]interface{} + wantErr bool + checkFn func(t *testing.T, msg *types.Message) + }{ + { + name: "SMS received", + formData: map[string]interface{}{ + "MessageSid": "test-message-sid", + "SmsStatus": "received", + "From": "+1234567890", + "To": "+0987654321", + "Body": "Hello from SMS", + }, + wantErr: false, + checkFn: func(t *testing.T, msg *types.Message) { + assert.Equal(t, types.MessageTypeSMS, msg.Type) + assert.Equal(t, "+1234567890", msg.From) + assert.Contains(t, msg.To, "+0987654321") + assert.Equal(t, "Hello from SMS", msg.Body) + assert.Equal(t, "Incoming Message", msg.Subject) + assert.Equal(t, "twilio", msg.Metadata["provider"]) + assert.Equal(t, "test-message-sid", msg.Metadata["message_sid"]) + assert.Equal(t, "received", msg.Metadata["sms_status"]) + }, + }, + { + name: "WhatsApp received", + formData: map[string]interface{}{ + "MessageSid": "whatsapp-message-sid", + "SmsStatus": "received", + "From": "whatsapp:+1234567890", + "To": "whatsapp:+0987654321", + "Body": "Hello from WhatsApp", + }, + wantErr: false, + checkFn: func(t *testing.T, msg *types.Message) { + assert.Equal(t, types.MessageTypeWhatsApp, msg.Type) + assert.Equal(t, "whatsapp:+1234567890", msg.From) + assert.Contains(t, msg.To, "whatsapp:+0987654321") + assert.Equal(t, "Hello from WhatsApp", msg.Body) + assert.Equal(t, "Incoming Message", msg.Subject) + }, + }, + { + name: "SMS delivered", + formData: map[string]interface{}{ + "MessageSid": "delivered-message-sid", + "SmsStatus": "delivered", + "From": "+1234567890", + "To": "+0987654321", + }, + wantErr: false, + checkFn: func(t *testing.T, msg *types.Message) { + assert.Equal(t, "Message Delivered", msg.Subject) + assert.Contains(t, msg.Body, "delivered") + }, + }, + { + name: "SMS failed", + formData: map[string]interface{}{ + "MessageSid": "failed-message-sid", + "SmsStatus": "failed", + "From": "+1234567890", + "To": "+0987654321", + "ErrorCode": "30001", + }, + wantErr: false, + checkFn: func(t *testing.T, msg *types.Message) { + assert.Equal(t, "Message Failed", msg.Subject) + assert.Contains(t, msg.Body, "failed") + assert.Contains(t, msg.Body, "30001") + assert.Equal(t, "30001", msg.Metadata["error_code"]) + }, + }, + { + name: "SMS queued", + formData: map[string]interface{}{ + "MessageSid": "queued-message-sid", + "SmsStatus": "queued", + "From": "+1234567890", + "To": "+0987654321", + "NumSegments": "1", + }, + wantErr: false, + checkFn: func(t *testing.T, msg *types.Message) { + assert.Equal(t, "Message Queued", msg.Subject) + assert.Contains(t, msg.Body, "queued") + assert.Equal(t, "1", msg.Metadata["num_segments"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtx := createMockGinContext(tt.formData) + + msg, err := provider.TriggerWebhook(mockCtx) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, msg) + + // Run specific checks + if tt.checkFn != nil { + tt.checkFn(t, msg) + } + + // Common checks + assert.NotNil(t, msg.Metadata) + assert.Equal(t, "twilio", msg.Metadata["provider"]) + }) + } +} + +func TestProvider_TriggerWebhook_InvalidInput(t *testing.T) { + config := types.ProviderConfig{ + Name: "test-twilio", + Connector: "twilio", + Options: map[string]interface{}{ + "account_sid": "test-account-sid", + "auth_token": "test-auth-token", + "from_phone": "+1234567890", + }, + } + + provider, err := NewTwilioProvider(config) + require.NoError(t, err) + + // Test with wrong input type + msg, err := provider.TriggerWebhook("not-gin-context") + assert.Error(t, err) + assert.Nil(t, msg) + assert.Contains(t, err.Error(), "expected *gin.Context") +} diff --git a/messenger/types/interfaces.go b/messenger/types/interfaces.go index ff8733a3..70ee2b06 100644 --- a/messenger/types/interfaces.go +++ b/messenger/types/interfaces.go @@ -13,8 +13,8 @@ type Provider interface { // SendBatch sends multiple messages in batch SendBatch(ctx context.Context, messages []*Message) error - // Receive processes incoming messages/responses from the provider - Receive(ctx context.Context, data map[string]interface{}) error + // TriggerWebhook processes webhook requests and converts to Message + TriggerWebhook(c interface{}) (*Message, error) // GetType returns the provider type (smtp, twilio, mailgun, etc.) GetType() string @@ -58,7 +58,7 @@ type Messenger interface { // TriggerWebhook processes incoming webhook data and triggers OnReceive handlers // This is used by OPENAPI endpoints to handle incoming messages - TriggerWebhook(ctx context.Context, providerName string, data map[string]interface{}) error + TriggerWebhook(providerName string, c interface{}) error // Close closes all provider connections Close() error diff --git a/openapi/messenger/messenger.go b/openapi/messenger/messenger.go index bfe615fd..00ad8278 100644 --- a/openapi/messenger/messenger.go +++ b/openapi/messenger/messenger.go @@ -1 +1,57 @@ package messenger + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/messenger" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// Attach attaches the messenger webhook handlers to the router +func Attach(group *gin.RouterGroup, oauth types.OAuth) { + + // Webhook endpoint with provider parameter - public interface + group.GET("/webhook/:provider", webhookHandler) + group.POST("/webhook/:provider", webhookHandler) +} + +// webhookHandler is the handler for webhook endpoint +func webhookHandler(c *gin.Context) { + // Get provider name from URL parameter + providerName := c.Param("provider") + if providerName == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "provider parameter is required", + }) + return + } + + // Check if messenger service is available + if messenger.Instance == nil { + log.Warn("[OpenAPI Messenger] Messenger service not initialized") + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "messenger service not available", + }) + return + } + + // Directly pass gin.Context to messenger service for processing + err := messenger.Instance.TriggerWebhook(providerName, c) + if err != nil { + log.Error("[OpenAPI Messenger] Failed to process webhook for provider %s: %v", providerName, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to process webhook", + "details": err.Error(), + }) + return + } + + // Return success response + c.JSON(http.StatusOK, gin.H{ + "status": "received", + "message": "webhook processed successfully", + "provider": providerName, + }) +} diff --git a/openapi/openapi.go b/openapi/openapi.go index 91d2a26b..82c273f3 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -13,6 +13,7 @@ import ( "github.com/yaoapp/yao/openapi/hello" "github.com/yaoapp/yao/openapi/job" "github.com/yaoapp/yao/openapi/kb" + "github.com/yaoapp/yao/openapi/messenger" "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/team" @@ -112,6 +113,9 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { // Team handlers team.Attach(group.Group("/team"), openapi.OAuth) + // Messenger webhook handlers + messenger.Attach(group.Group("/messenger"), openapi.OAuth) + // Custom handlers (Defined by developer) }