Refactor messenger service to enhance webhook processing and remove deprecated methods
- Implemented TriggerWebhook method to handle incoming webhook requests for various providers, utilizing gin.Context for improved integration. - Removed the deprecated Receive method from the mailer provider and related functions, streamlining the codebase. - Updated tests to reflect changes in webhook handling and provider interactions, ensuring compatibility with new implementations. - Enhanced error handling and logging for webhook processing, improving overall reliability and clarity.
This commit is contained in:
parent
c425b74383
commit
0505adf003
12 changed files with 799 additions and 1274 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,19 +178,25 @@ 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if providerName != "" {
|
||||
// Register a handler to capture the triggered message
|
||||
var receivedMessage *types.Message
|
||||
var mu sync.Mutex
|
||||
|
|
@ -179,9 +212,35 @@ func TestService_TriggerWebhook(t *testing.T) {
|
|||
err = service.OnReceive(handler)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Trigger webhook
|
||||
err = service.TriggerWebhook(ctx, providerName, webhookData)
|
||||
// Note: This might fail if the provider's Receive method has validation,
|
||||
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)
|
||||
|
|
@ -193,152 +252,31 @@ func TestService_TriggerWebhook(t *testing.T) {
|
|||
// 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")
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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": "<p>Email HTML content</p>",
|
||||
},
|
||||
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)
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
t.Log("No providers support TriggerWebhook - skipping webhook test")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check message type
|
||||
if tt.data["type"] != nil {
|
||||
assert.Equal(t, tt.expectedType, message.Type)
|
||||
}
|
||||
|
||||
// Check metadata
|
||||
assert.NotNil(t, message.Metadata)
|
||||
assert.Equal(t, tt.providerName, message.Metadata["provider"])
|
||||
assert.Equal(t, tt.data, message.Metadata["webhook_data"])
|
||||
})
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
|
@ -430,22 +368,51 @@ 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
|
@ -457,10 +424,26 @@ func TestMessenger_OnReceiveIntegration(t *testing.T) {
|
|||
// 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)
|
||||
assert.Equal(t, "Integration Test", receivedMessage.Subject)
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up - remove handler
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -29,36 +29,9 @@ 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)
|
||||
// Note: The Receive method has been removed as it's replaced by TriggerWebhook
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Check if this provider supports receiving
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 <email@domain.com>" format
|
||||
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}, // Send to ourselves
|
||||
Subject: testSubject,
|
||||
Body: testBody,
|
||||
HTML: "<h1>Integration Test</h1><p>" + testBody + "</p>",
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
// For now, just log the received data
|
||||
fmt.Printf("Mailgun provider received data: %+v\n", data)
|
||||
|
||||
return nil
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
171
messenger/providers/mailgun/mailgun_receive_test.go
Normal file
171
messenger/providers/mailgun/mailgun_receive_test.go
Normal file
|
|
@ -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": "<p>Email HTML content</p>",
|
||||
},
|
||||
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, "<p>Email HTML content</p>", 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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
// For now, just log the received data
|
||||
fmt.Printf("Twilio provider received data: %+v\n", data)
|
||||
|
||||
return nil
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
193
messenger/providers/twilio/twilio_receive_test.go
Normal file
193
messenger/providers/twilio/twilio_receive_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue