diff --git a/messenger/messenger.go b/messenger/messenger.go index 59ad3350..e48cbb0b 100644 --- a/messenger/messenger.go +++ b/messenger/messenger.go @@ -18,6 +18,7 @@ import ( "github.com/yaoapp/yao/messenger/providers/mailer" "github.com/yaoapp/yao/messenger/providers/mailgun" "github.com/yaoapp/yao/messenger/providers/twilio" + "github.com/yaoapp/yao/messenger/template" "github.com/yaoapp/yao/messenger/types" "github.com/yaoapp/yao/share" ) @@ -73,6 +74,13 @@ func Load(cfg config.Config) error { return err } + // Load templates + err = template.LoadTemplates() + if err != nil { + log.Warn("[Messenger] Failed to load templates: %v", err) + // Don't fail messenger loading if templates fail + } + // Create messenger configuration config := &types.Config{ Providers: []types.ProviderConfig{}, @@ -212,7 +220,7 @@ func createProvider(config types.ProviderConfig) (types.Provider, error) { case "twilio": return createTwilioProvider(config) case "mailgun": - return mailgun.NewMailgunProvider(config) + return createMailgunProvider(config) default: return nil, fmt.Errorf("unsupported connector: %s", connector) } @@ -220,7 +228,12 @@ func createProvider(config types.ProviderConfig) (types.Provider, error) { // createTwilioProvider creates a unified Twilio provider that handles all message types func createTwilioProvider(config types.ProviderConfig) (types.Provider, error) { - return twilio.NewTwilioProvider(config) + return twilio.NewTwilioProviderWithTemplateManager(config, template.Global) +} + +// createMailgunProvider creates a Mailgun provider with template manager +func createMailgunProvider(config types.ProviderConfig) (types.Provider, error) { + return mailgun.NewMailgunProviderWithTemplateManager(config, template.Global) } // Send sends a message using the specified channel or default provider @@ -286,6 +299,81 @@ func (m *Service) SendWithProvider(ctx context.Context, providerName string, mes return fmt.Errorf("failed to send message after %d attempts: %w", maxAttempts, lastErr) } +// SendT sends a message using a template +func (m *Service) SendT(ctx context.Context, channel string, templateID string, data types.TemplateData) error { + // Get providers for the channel + providers := m.GetProviders(channel) + if len(providers) == 0 { + return fmt.Errorf("no providers available for channel: %s", channel) + } + + // Use the first available provider's SendT method + provider := providers[0] + return provider.SendT(ctx, templateID, data) +} + +// SendTWithProvider sends a message using a template and specific provider +func (m *Service) SendTWithProvider(ctx context.Context, providerName string, templateID string, data types.TemplateData) error { + // Get provider + provider, exists := m.providers[providerName] + if !exists { + return fmt.Errorf("provider not found: %s", providerName) + } + + // Use the provider's SendT method directly + return provider.SendT(ctx, templateID, data) +} + +// SendTBatch sends multiple messages using templates in batch +func (m *Service) SendTBatch(ctx context.Context, channel string, templateID string, dataList []types.TemplateData) error { + // Get providers for the channel + providers := m.GetProviders(channel) + if len(providers) == 0 { + return fmt.Errorf("no providers available for channel: %s", channel) + } + + // Use the first available provider's SendTBatch method + provider := providers[0] + return provider.SendTBatch(ctx, templateID, dataList) +} + +// SendTBatchWithProvider sends multiple messages using templates and specific provider in batch +func (m *Service) SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []types.TemplateData) error { + // Get provider + provider, exists := m.providers[providerName] + if !exists { + return fmt.Errorf("provider not found: %s", providerName) + } + + // Use the provider's SendTBatch method directly + return provider.SendTBatch(ctx, templateID, dataList) +} + +// SendTBatchMixed sends multiple messages using different templates with different data +func (m *Service) SendTBatchMixed(ctx context.Context, channel string, templateRequests []types.TemplateRequest) error { + // Get providers for the channel + providers := m.GetProviders(channel) + if len(providers) == 0 { + return fmt.Errorf("no providers available for channel: %s", channel) + } + + // Use the first available provider's SendTBatchMixed method + provider := providers[0] + return provider.SendTBatchMixed(ctx, templateRequests) +} + +// SendTBatchMixedWithProvider sends multiple messages using different templates with different data and specific provider +func (m *Service) SendTBatchMixedWithProvider(ctx context.Context, providerName string, templateRequests []types.TemplateRequest) error { + // Get provider + provider, exists := m.providers[providerName] + if !exists { + return fmt.Errorf("provider not found: %s", providerName) + } + + // Use the provider's SendTBatchMixed method directly + return provider.SendTBatchMixed(ctx, templateRequests) +} + // SendBatch sends multiple messages in batch func (m *Service) SendBatch(ctx context.Context, channel string, messages []*types.Message) error { if len(messages) == 0 { diff --git a/messenger/providers/mailer/mailer.go b/messenger/providers/mailer/mailer.go index a0041683..27e532a6 100644 --- a/messenger/providers/mailer/mailer.go +++ b/messenger/providers/mailer/mailer.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/yaoapp/yao/messenger/template" "github.com/yaoapp/yao/messenger/types" ) @@ -32,13 +33,22 @@ type Provider struct { imapPassword string imapUseSSL bool imapMailbox string + + // Template manager for template support + templateManager types.TemplateManager } // NewMailerProvider creates a new Mailer provider func NewMailerProvider(config types.ProviderConfig) (*Provider, error) { + return NewMailerProviderWithTemplateManager(config, template.Global) +} + +// NewMailerProviderWithTemplateManager creates a new Mailer provider with template manager +func NewMailerProviderWithTemplateManager(config types.ProviderConfig, templateManager types.TemplateManager) (*Provider, error) { provider := &Provider{ - config: config, - useTLS: true, // Default to TLS + config: config, + useTLS: true, // Default to TLS + templateManager: templateManager, } // Extract options @@ -199,6 +209,77 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err return nil } +// SendT sends a message using a template +func (p *Provider) SendT(ctx context.Context, templateID string, data types.TemplateData) error { + // Get template from provider's template manager (mailer supports mail templates) + template, err := p.getTemplate(templateID, types.TemplateTypeMail) + if err != nil { + return fmt.Errorf("template not found: %w", err) + } + + // Convert template to message + message, err := template.ToMessage(data) + if err != nil { + return fmt.Errorf("failed to convert template to message: %w", err) + } + + // Send message using existing Send method + return p.Send(ctx, message) +} + +// SendTBatch sends multiple messages using templates in batch +func (p *Provider) SendTBatch(ctx context.Context, templateID string, dataList []types.TemplateData) error { + // Get template from provider's template manager (mailer supports mail templates) + template, err := p.getTemplate(templateID, types.TemplateTypeMail) + if err != nil { + return fmt.Errorf("template not found: %w", err) + } + + // Convert templates to messages + messages := make([]*types.Message, 0, len(dataList)) + for _, data := range dataList { + message, err := template.ToMessage(data) + if err != nil { + return fmt.Errorf("failed to convert template to message: %w", err) + } + messages = append(messages, message) + } + + // Send messages using existing SendBatch method + return p.SendBatch(ctx, messages) +} + +// SendTBatchMixed sends multiple messages using different templates with different data +func (p *Provider) SendTBatchMixed(ctx context.Context, templateRequests []types.TemplateRequest) error { + // Convert template requests to messages + messages := make([]*types.Message, 0, len(templateRequests)) + for _, req := range templateRequests { + // Get template from provider's template manager (mailer supports mail templates) + template, err := p.getTemplate(req.TemplateID, types.TemplateTypeMail) + if err != nil { + return fmt.Errorf("template not found: %s, %w", req.TemplateID, err) + } + + // Convert template to message + message, err := template.ToMessage(req.Data) + if err != nil { + return fmt.Errorf("failed to convert template %s to message: %w", req.TemplateID, err) + } + messages = append(messages, message) + } + + // Send messages using existing SendBatch method + return p.SendBatch(ctx, messages) +} + +// getTemplate gets a template by ID and type from the provider's template manager +func (p *Provider) getTemplate(templateID string, templateType types.TemplateType) (*types.Template, error) { + if p.templateManager == nil { + return nil, fmt.Errorf("template manager not available") + } + return p.templateManager.GetTemplate(templateID, templateType) +} + // GetType returns the provider type func (p *Provider) GetType() string { return "mailer" diff --git a/messenger/providers/mailer/mailer_batch_test.go b/messenger/providers/mailer/mailer_batch_test.go new file mode 100644 index 00000000..05b801c7 --- /dev/null +++ b/messenger/providers/mailer/mailer_batch_test.go @@ -0,0 +1,200 @@ +package mailer + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/messenger/types" + "github.com/yaoapp/yao/test" +) + +func TestSendTBatch_Success(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider with template manager + config := types.ProviderConfig{ + Name: "test-mailer", + Connector: "mailer", + Options: map[string]interface{}{ + "smtp": map[string]interface{}{ + "host": "localhost", + "port": 587, + "username": "test@example.com", + "password": "password", + "from": "test@example.com", + }, + }, + } + + provider, err := NewMailerProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data for batch sending + dataList := []types.TemplateData{ + { + "to": []string{"user1@example.com"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + { + "to": []string{"user2@example.com"}, + "team_name": "Team B", + "inviter_name": "Bob", + "invite_link": "https://example.com/invite/2", + }, + } + + // Test SendTBatch - should fail because template manager is nil + err = provider.SendTBatch(context.Background(), "en.invite_member", dataList) + assert.Error(t, err) + assert.Contains(t, err.Error(), "template manager not available") +} + +func TestSendTBatch_ContextTimeout(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider + config := types.ProviderConfig{ + Name: "test-mailer", + Connector: "mailer", + Options: map[string]interface{}{ + "smtp": map[string]interface{}{ + "host": "localhost", + "port": 587, + "username": "test@example.com", + "password": "password", + "from": "test@example.com", + }, + }, + } + + provider, err := NewMailerProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data + dataList := []types.TemplateData{ + { + "to": []string{"user1@example.com"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Wait for context to timeout + time.Sleep(2 * time.Nanosecond) + + // Test SendTBatch with expired context + err = provider.SendTBatch(ctx, "en.invite_member", dataList) + assert.Error(t, err) + // Error could be either "template manager not available" or "context deadline exceeded" + t.Logf("Error: %v", err) +} + +func TestSendTBatchMixed_Success(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider with template manager + config := types.ProviderConfig{ + Name: "test-mailer", + Connector: "mailer", + Options: map[string]interface{}{ + "smtp": map[string]interface{}{ + "host": "localhost", + "port": 587, + "username": "test@example.com", + "password": "password", + "from": "test@example.com", + }, + }, + } + + provider, err := NewMailerProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data for mixed batch sending + templateRequests := []types.TemplateRequest{ + { + TemplateID: "en.invite_member", + Data: types.TemplateData{ + "to": []string{"user1@example.com"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + }, + { + TemplateID: "en.welcome", + Data: types.TemplateData{ + "to": []string{"user2@example.com"}, + "user_name": "Bob", + "company": "Example Corp", + }, + }, + } + + // Test SendTBatchMixed - should fail because template manager is nil + err = provider.SendTBatchMixed(context.Background(), templateRequests) + assert.Error(t, err) + assert.Contains(t, err.Error(), "template manager not available") +} + +func TestSendTBatchMixed_ContextTimeout(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider + config := types.ProviderConfig{ + Name: "test-mailer", + Connector: "mailer", + Options: map[string]interface{}{ + "smtp": map[string]interface{}{ + "host": "localhost", + "port": 587, + "username": "test@example.com", + "password": "password", + "from": "test@example.com", + }, + }, + } + + provider, err := NewMailerProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data + templateRequests := []types.TemplateRequest{ + { + TemplateID: "en.invite_member", + Data: types.TemplateData{ + "to": []string{"user1@example.com"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + }, + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Wait for context to timeout + time.Sleep(2 * time.Nanosecond) + + // Test SendTBatchMixed with expired context + err = provider.SendTBatchMixed(ctx, templateRequests) + assert.Error(t, err) + // Error could be either "template manager not available" or "context deadline exceeded" + t.Logf("Error: %v", err) +} diff --git a/messenger/providers/mailer/mailer_template_test.go b/messenger/providers/mailer/mailer_template_test.go new file mode 100644 index 00000000..c064480f --- /dev/null +++ b/messenger/providers/mailer/mailer_template_test.go @@ -0,0 +1,178 @@ +package mailer + +import ( + "context" + "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 SendT method + +func TestSendT_TemplateNotImplemented(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Create a simple provider config for testing + config := types.ProviderConfig{ + Name: "test-mailer", + Connector: "mailer", + Options: map[string]interface{}{ + "smtp": map[string]interface{}{ + "host": "smtp.example.com", + "port": 587, + "username": "test@example.com", + "password": "testpass", + "from": "test@example.com", + "use_tls": true, + }, + }, + } + + provider, err := NewMailerProvider(config) + require.NoError(t, err) + + ctx := context.Background() + templateData := types.TemplateData{ + "to": []string{"test@example.com"}, + "team_name": "Test Team", + "inviter_name": "John Doe", + "invite_link": "https://example.com/invite/123", + } + + // Test that SendT returns "template not found" error (template system is working) + err = provider.SendT(ctx, "en.invite_member.mail", templateData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} + +func TestSendT_ContextTimeout(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Create a simple provider config for testing + config := types.ProviderConfig{ + Name: "test-mailer", + Connector: "mailer", + Options: map[string]interface{}{ + "smtp": map[string]interface{}{ + "host": "smtp.example.com", + "port": 587, + "username": "test@example.com", + "password": "testpass", + "from": "test@example.com", + "use_tls": true, + }, + }, + } + + provider, err := NewMailerProvider(config) + require.NoError(t, err) + + // Create a very short timeout context to test timeout functionality + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + templateData := types.TemplateData{ + "to": []string{"test@example.com"}, + "team_name": "Test Team", + "inviter_name": "John Doe", + "invite_link": "https://example.com/invite/123", + } + + err = provider.SendT(ctx, "en.invite_member.mail", templateData) + assert.Error(t, err) + + // Verify it's a context timeout error or not implemented error + if strings.Contains(err.Error(), "context deadline exceeded") { + t.Log("Context timeout working correctly with template API") + } else if strings.Contains(err.Error(), "context canceled") { + t.Log("Context cancellation working correctly with template API") + } else if strings.Contains(err.Error(), "template not found") { + t.Log("Template not found error as expected") + } else { + t.Logf("Got different error: %v", err) + } +} + +// Test template system integration + +func TestTemplateSystem_LoadTemplates(t *testing.T) { + // This test verifies that the template system can be loaded + // We'll test the template loading logic + + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Test that we can create a template data structure + templateData := types.TemplateData{ + "team_name": "Awesome Team", + "inviter_name": "Alice Johnson", + "invite_link": "https://example.com/invite/abc123", + "to": []string{"test@example.com"}, + } + + // Verify template data structure + assert.NotNil(t, templateData) + assert.Equal(t, "Awesome Team", templateData["team_name"]) + assert.Equal(t, "Alice Johnson", templateData["inviter_name"]) + assert.Equal(t, "https://example.com/invite/abc123", templateData["invite_link"]) + + // Verify recipients + recipients, ok := templateData["to"].([]string) + assert.True(t, ok) + assert.Len(t, recipients, 1) + assert.Equal(t, "test@example.com", recipients[0]) +} + +// Benchmark Tests + +func BenchmarkSendT(b *testing.B) { + // Setup + t := &testing.T{} + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + config := types.ProviderConfig{ + Name: "test-mailer", + Connector: "mailer", + Options: map[string]interface{}{ + "smtp": map[string]interface{}{ + "host": "smtp.example.com", + "port": 587, + "username": "test@example.com", + "password": "testpass", + "from": "test@example.com", + "use_tls": true, + }, + }, + } + + provider, err := NewMailerProvider(config) + if err != nil { + b.Fatal(err) + } + + ctx := context.Background() + templateData := types.TemplateData{ + "to": []string{"test@example.com"}, + "team_name": "Test Team", + "inviter_name": "John Doe", + "invite_link": "https://example.com/invite/123", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // This will return "not implemented" error, but we're measuring the overhead + _ = provider.SendT(ctx, "en.invite_member.mail", templateData) + } +} diff --git a/messenger/providers/mailgun/mailgun.go b/messenger/providers/mailgun/mailgun.go index 9b0ce30a..a0282350 100644 --- a/messenger/providers/mailgun/mailgun.go +++ b/messenger/providers/mailgun/mailgun.go @@ -14,18 +14,25 @@ import ( // Provider implements the Provider interface for Mailgun email sending type Provider struct { - config types.ProviderConfig - domain string - apiKey string - from string - baseURL string - httpClient *http.Client + config types.ProviderConfig + domain string + apiKey string + from string + baseURL string + httpClient *http.Client + templateManager types.TemplateManager } // NewMailgunProvider creates a new Mailgun provider func NewMailgunProvider(config types.ProviderConfig) (*Provider, error) { + return NewMailgunProviderWithTemplateManager(config, nil) +} + +// NewMailgunProviderWithTemplateManager creates a new Mailgun provider with template manager +func NewMailgunProviderWithTemplateManager(config types.ProviderConfig, templateManager types.TemplateManager) (*Provider, error) { provider := &Provider{ - config: config, + config: config, + templateManager: templateManager, httpClient: &http.Client{ Timeout: 30 * time.Second, }, @@ -86,6 +93,77 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err return nil } +// SendT sends a message using a template +func (p *Provider) SendT(ctx context.Context, templateID string, data types.TemplateData) error { + // Get template from provider's template manager + template, err := p.getTemplate(templateID, types.TemplateTypeMail) // Mailgun supports email + if err != nil { + return fmt.Errorf("template not found: %w", err) + } + + // Convert template to message + message, err := template.ToMessage(data) + if err != nil { + return fmt.Errorf("failed to convert template to message: %w", err) + } + + // Send message using existing Send method + return p.Send(ctx, message) +} + +// SendTBatch sends multiple messages using templates in batch +func (p *Provider) SendTBatch(ctx context.Context, templateID string, dataList []types.TemplateData) error { + // Get template from provider's template manager + template, err := p.getTemplate(templateID, types.TemplateTypeMail) // Mailgun supports email + if err != nil { + return fmt.Errorf("template not found: %w", err) + } + + // Convert templates to messages + messages := make([]*types.Message, 0, len(dataList)) + for _, data := range dataList { + message, err := template.ToMessage(data) + if err != nil { + return fmt.Errorf("failed to convert template to message: %w", err) + } + messages = append(messages, message) + } + + // Send messages using existing SendBatch method + return p.SendBatch(ctx, messages) +} + +// SendTBatchMixed sends multiple messages using different templates with different data +func (p *Provider) SendTBatchMixed(ctx context.Context, templateRequests []types.TemplateRequest) error { + // Convert template requests to messages + messages := make([]*types.Message, 0, len(templateRequests)) + for _, req := range templateRequests { + // Get template from provider's template manager + template, err := p.getTemplate(req.TemplateID, types.TemplateTypeMail) // Mailgun supports email + if err != nil { + return fmt.Errorf("template not found: %s, %w", req.TemplateID, err) + } + + // Convert template to message + message, err := template.ToMessage(req.Data) + if err != nil { + return fmt.Errorf("failed to convert template %s to message: %w", req.TemplateID, err) + } + messages = append(messages, message) + } + + // Send messages using existing SendBatch method + return p.SendBatch(ctx, messages) +} + +// getTemplate gets a template by ID and type from the provider's template manager +func (p *Provider) getTemplate(templateID string, templateType types.TemplateType) (*types.Template, error) { + if p.templateManager == nil { + return nil, fmt.Errorf("template manager not available") + } + return p.templateManager.GetTemplate(templateID, templateType) +} + // GetType returns the provider type func (p *Provider) GetType() string { return "mailgun" diff --git a/messenger/providers/mailgun/mailgun_batch_test.go b/messenger/providers/mailgun/mailgun_batch_test.go new file mode 100644 index 00000000..a1b040c6 --- /dev/null +++ b/messenger/providers/mailgun/mailgun_batch_test.go @@ -0,0 +1,184 @@ +package mailgun + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/messenger/types" + "github.com/yaoapp/yao/test" +) + +func TestSendTBatch_Success(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider with template manager + config := types.ProviderConfig{ + Name: "test-mailgun", + Connector: "mailgun", + Options: map[string]interface{}{ + "domain": "test.example.com", + "api_key": "test_api_key", + "from": "test@example.com", + }, + } + + provider, err := NewMailgunProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data for batch sending + dataList := []types.TemplateData{ + { + "to": []string{"user1@example.com"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + { + "to": []string{"user2@example.com"}, + "team_name": "Team B", + "inviter_name": "Bob", + "invite_link": "https://example.com/invite/2", + }, + } + + // Test SendTBatch - should fail because template manager is nil + err = provider.SendTBatch(context.Background(), "en.invite_member", dataList) + assert.Error(t, err) + assert.Contains(t, err.Error(), "template manager not available") +} + +func TestSendTBatch_ContextTimeout(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider + config := types.ProviderConfig{ + Name: "test-mailgun", + Connector: "mailgun", + Options: map[string]interface{}{ + "domain": "test.example.com", + "api_key": "test_api_key", + "from": "test@example.com", + }, + } + + provider, err := NewMailgunProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data + dataList := []types.TemplateData{ + { + "to": []string{"user1@example.com"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Wait for context to timeout + time.Sleep(2 * time.Nanosecond) + + // Test SendTBatch with expired context + err = provider.SendTBatch(ctx, "en.invite_member", dataList) + assert.Error(t, err) + // Error could be either "template manager not available" or "context deadline exceeded" + t.Logf("Error: %v", err) +} + +func TestSendTBatchMixed_Success(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider with template manager + config := types.ProviderConfig{ + Name: "test-mailgun", + Connector: "mailgun", + Options: map[string]interface{}{ + "domain": "test.example.com", + "api_key": "test_api_key", + "from": "test@example.com", + }, + } + + provider, err := NewMailgunProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data for mixed batch sending + templateRequests := []types.TemplateRequest{ + { + TemplateID: "en.invite_member", + Data: types.TemplateData{ + "to": []string{"user1@example.com"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + }, + { + TemplateID: "en.welcome", + Data: types.TemplateData{ + "to": []string{"user2@example.com"}, + "user_name": "Bob", + "company": "Example Corp", + }, + }, + } + + // Test SendTBatchMixed - should fail because template manager is nil + err = provider.SendTBatchMixed(context.Background(), templateRequests) + assert.Error(t, err) + assert.Contains(t, err.Error(), "template manager not available") +} + +func TestSendTBatchMixed_ContextTimeout(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider + config := types.ProviderConfig{ + Name: "test-mailgun", + Connector: "mailgun", + Options: map[string]interface{}{ + "domain": "test.example.com", + "api_key": "test_api_key", + "from": "test@example.com", + }, + } + + provider, err := NewMailgunProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data + templateRequests := []types.TemplateRequest{ + { + TemplateID: "en.invite_member", + Data: types.TemplateData{ + "to": []string{"user1@example.com"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + }, + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Wait for context to timeout + time.Sleep(2 * time.Nanosecond) + + // Test SendTBatchMixed with expired context + err = provider.SendTBatchMixed(ctx, templateRequests) + assert.Error(t, err) + // Error could be either "template manager not available" or "context deadline exceeded" + t.Logf("Error: %v", err) +} diff --git a/messenger/providers/mailgun/mailgun_template_test.go b/messenger/providers/mailgun/mailgun_template_test.go new file mode 100644 index 00000000..1bf43eb8 --- /dev/null +++ b/messenger/providers/mailgun/mailgun_template_test.go @@ -0,0 +1,96 @@ +package mailgun + +import ( + "context" + "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" +) + +func TestSendT_TemplateNotImplemented(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Create provider with minimal config + config := types.ProviderConfig{ + Name: "test-mailgun", + Connector: "mailgun", + Options: map[string]interface{}{ + "api_key": "test_api_key", + "domain": "test.example.com", + "from": "test@example.com", + }, + } + + provider, err := NewMailgunProvider(config) + require.NoError(t, err) + + ctx := context.Background() + templateData := types.TemplateData{ + "to": []string{"test@example.com"}, + "team_name": "Test Team", + "inviter_name": "John Doe", + "invite_link": "https://example.com/invite/123", + } + + // Test that SendT returns "template manager not available" error + err = provider.SendT(ctx, "en.invite_member", templateData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "template manager not available") +} + +func TestSendT_ContextTimeout(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Create provider with minimal config + config := types.ProviderConfig{ + Name: "test-mailgun", + Connector: "mailgun", + Options: map[string]interface{}{ + "api_key": "test_api_key", + "domain": "test.example.com", + "from": "test@example.com", + }, + } + + provider, err := NewMailgunProvider(config) + require.NoError(t, err) + + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + // Wait for timeout + time.Sleep(2 * time.Millisecond) + + templateData := types.TemplateData{ + "to": []string{"test@example.com"}, + "team_name": "Test Team", + "inviter_name": "John Doe", + "invite_link": "https://example.com/invite/123", + } + + // Test that SendT handles context timeout + err = provider.SendT(ctx, "en.invite_member", templateData) + assert.Error(t, err) + + // Verify it's a context timeout error or template manager error + if strings.Contains(err.Error(), "context deadline exceeded") { + t.Log("Context timeout working correctly with template API") + } else if strings.Contains(err.Error(), "context canceled") { + t.Log("Context cancellation working correctly with template API") + } else if strings.Contains(err.Error(), "template manager not available") { + t.Log("Template manager not available error as expected") + } else { + t.Logf("Got different error: %v", err) + } +} diff --git a/messenger/providers/twilio/twilio.go b/messenger/providers/twilio/twilio.go index c6a3c843..5d1bec79 100644 --- a/messenger/providers/twilio/twilio.go +++ b/messenger/providers/twilio/twilio.go @@ -28,12 +28,19 @@ type Provider struct { sendGridAPIKey string httpClient *http.Client baseURL string + templateManager types.TemplateManager } // NewTwilioProvider creates a new unified Twilio provider func NewTwilioProvider(config types.ProviderConfig) (*Provider, error) { + return NewTwilioProviderWithTemplateManager(config, nil) +} + +// NewTwilioProviderWithTemplateManager creates a new Twilio provider with template manager +func NewTwilioProviderWithTemplateManager(config types.ProviderConfig, templateManager types.TemplateManager) (*Provider, error) { provider := &Provider{ - config: config, + config: config, + templateManager: templateManager, httpClient: &http.Client{ Timeout: 30 * time.Second, }, @@ -118,6 +125,77 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err return nil } +// SendT sends a message using a template +func (p *Provider) SendT(ctx context.Context, templateID string, data types.TemplateData) error { + // Get template from provider's template manager + template, err := p.getTemplate(templateID, types.TemplateTypeSMS) // Twilio primarily supports SMS + if err != nil { + return fmt.Errorf("template not found: %w", err) + } + + // Convert template to message + message, err := template.ToMessage(data) + if err != nil { + return fmt.Errorf("failed to convert template to message: %w", err) + } + + // Send message using existing Send method + return p.Send(ctx, message) +} + +// SendTBatch sends multiple messages using templates in batch +func (p *Provider) SendTBatch(ctx context.Context, templateID string, dataList []types.TemplateData) error { + // Get template from provider's template manager + template, err := p.getTemplate(templateID, types.TemplateTypeSMS) // Twilio primarily supports SMS + if err != nil { + return fmt.Errorf("template not found: %w", err) + } + + // Convert templates to messages + messages := make([]*types.Message, 0, len(dataList)) + for _, data := range dataList { + message, err := template.ToMessage(data) + if err != nil { + return fmt.Errorf("failed to convert template to message: %w", err) + } + messages = append(messages, message) + } + + // Send messages using existing SendBatch method + return p.SendBatch(ctx, messages) +} + +// SendTBatchMixed sends multiple messages using different templates with different data +func (p *Provider) SendTBatchMixed(ctx context.Context, templateRequests []types.TemplateRequest) error { + // Convert template requests to messages + messages := make([]*types.Message, 0, len(templateRequests)) + for _, req := range templateRequests { + // Get template from provider's template manager + template, err := p.getTemplate(req.TemplateID, types.TemplateTypeSMS) // Twilio primarily supports SMS + if err != nil { + return fmt.Errorf("template not found: %s, %w", req.TemplateID, err) + } + + // Convert template to message + message, err := template.ToMessage(req.Data) + if err != nil { + return fmt.Errorf("failed to convert template %s to message: %w", req.TemplateID, err) + } + messages = append(messages, message) + } + + // Send messages using existing SendBatch method + return p.SendBatch(ctx, messages) +} + +// getTemplate gets a template by ID and type from the provider's template manager +func (p *Provider) getTemplate(templateID string, templateType types.TemplateType) (*types.Template, error) { + if p.templateManager == nil { + return nil, fmt.Errorf("template manager not available") + } + return p.templateManager.GetTemplate(templateID, templateType) +} + // GetType returns the provider type func (p *Provider) GetType() string { return "twilio" diff --git a/messenger/providers/twilio/twilio_batch_test.go b/messenger/providers/twilio/twilio_batch_test.go new file mode 100644 index 00000000..44b8134a --- /dev/null +++ b/messenger/providers/twilio/twilio_batch_test.go @@ -0,0 +1,184 @@ +package twilio + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/messenger/types" + "github.com/yaoapp/yao/test" +) + +func TestSendTBatch_Success(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider with template manager + config := types.ProviderConfig{ + Name: "test-twilio", + Connector: "twilio", + Options: map[string]interface{}{ + "account_sid": "test_account_sid", + "auth_token": "test_auth_token", + "from": "+1234567890", + }, + } + + provider, err := NewTwilioProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data for batch sending + dataList := []types.TemplateData{ + { + "to": []string{"+1234567890"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + { + "to": []string{"+0987654321"}, + "team_name": "Team B", + "inviter_name": "Bob", + "invite_link": "https://example.com/invite/2", + }, + } + + // Test SendTBatch - should fail because template manager is nil + err = provider.SendTBatch(context.Background(), "en.invite_member", dataList) + assert.Error(t, err) + assert.Contains(t, err.Error(), "template manager not available") +} + +func TestSendTBatch_ContextTimeout(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider + config := types.ProviderConfig{ + Name: "test-twilio", + Connector: "twilio", + Options: map[string]interface{}{ + "account_sid": "test_account_sid", + "auth_token": "test_auth_token", + "from": "+1234567890", + }, + } + + provider, err := NewTwilioProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data + dataList := []types.TemplateData{ + { + "to": []string{"+1234567890"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Wait for context to timeout + time.Sleep(2 * time.Nanosecond) + + // Test SendTBatch with expired context + err = provider.SendTBatch(ctx, "en.invite_member", dataList) + assert.Error(t, err) + // Error could be either "template manager not available" or "context deadline exceeded" + t.Logf("Error: %v", err) +} + +func TestSendTBatchMixed_Success(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider with template manager + config := types.ProviderConfig{ + Name: "test-twilio", + Connector: "twilio", + Options: map[string]interface{}{ + "account_sid": "test_account_sid", + "auth_token": "test_auth_token", + "from": "+1234567890", + }, + } + + provider, err := NewTwilioProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data for mixed batch sending + templateRequests := []types.TemplateRequest{ + { + TemplateID: "en.invite_member", + Data: types.TemplateData{ + "to": []string{"+1234567890"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + }, + { + TemplateID: "en.welcome", + Data: types.TemplateData{ + "to": []string{"+0987654321"}, + "user_name": "Bob", + "company": "Example Corp", + }, + }, + } + + // Test SendTBatchMixed - should fail because template manager is nil + err = provider.SendTBatchMixed(context.Background(), templateRequests) + assert.Error(t, err) + assert.Contains(t, err.Error(), "template manager not available") +} + +func TestSendTBatchMixed_ContextTimeout(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + + // Create provider + config := types.ProviderConfig{ + Name: "test-twilio", + Connector: "twilio", + Options: map[string]interface{}{ + "account_sid": "test_account_sid", + "auth_token": "test_auth_token", + "from": "+1234567890", + }, + } + + provider, err := NewTwilioProviderWithTemplateManager(config, nil) + assert.NoError(t, err) + + // Test data + templateRequests := []types.TemplateRequest{ + { + TemplateID: "en.invite_member", + Data: types.TemplateData{ + "to": []string{"+1234567890"}, + "team_name": "Team A", + "inviter_name": "Alice", + "invite_link": "https://example.com/invite/1", + }, + }, + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Wait for context to timeout + time.Sleep(2 * time.Nanosecond) + + // Test SendTBatchMixed with expired context + err = provider.SendTBatchMixed(ctx, templateRequests) + assert.Error(t, err) + // Error could be either "template manager not available" or "context deadline exceeded" + t.Logf("Error: %v", err) +} diff --git a/messenger/providers/twilio/twilio_template_test.go b/messenger/providers/twilio/twilio_template_test.go new file mode 100644 index 00000000..001a1958 --- /dev/null +++ b/messenger/providers/twilio/twilio_template_test.go @@ -0,0 +1,94 @@ +package twilio + +import ( + "context" + "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" +) + +func TestSendT_TemplateNotImplemented(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Create provider with minimal config + config := types.ProviderConfig{ + Name: "test-twilio", + Connector: "twilio", + Options: map[string]interface{}{ + "account_sid": "test_account_sid", + "auth_token": "test_auth_token", + }, + } + + provider, err := NewTwilioProvider(config) + require.NoError(t, err) + + ctx := context.Background() + templateData := types.TemplateData{ + "to": []string{"+1234567890"}, + "team_name": "Test Team", + "inviter_name": "John Doe", + "invite_link": "https://example.com/invite/123", + } + + // Test that SendT returns "template manager not available" error + err = provider.SendT(ctx, "en.invite_member", templateData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "template manager not available") +} + +func TestSendT_ContextTimeout(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Create provider with minimal config + config := types.ProviderConfig{ + Name: "test-twilio", + Connector: "twilio", + Options: map[string]interface{}{ + "account_sid": "test_account_sid", + "auth_token": "test_auth_token", + }, + } + + provider, err := NewTwilioProvider(config) + require.NoError(t, err) + + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + // Wait for timeout + time.Sleep(2 * time.Millisecond) + + templateData := types.TemplateData{ + "to": []string{"+1234567890"}, + "team_name": "Test Team", + "inviter_name": "John Doe", + "invite_link": "https://example.com/invite/123", + } + + // Test that SendT handles context timeout + err = provider.SendT(ctx, "en.invite_member", templateData) + assert.Error(t, err) + + // Verify it's a context timeout error or template manager error + if strings.Contains(err.Error(), "context deadline exceeded") { + t.Log("Context timeout working correctly with template API") + } else if strings.Contains(err.Error(), "context canceled") { + t.Log("Context cancellation working correctly with template API") + } else if strings.Contains(err.Error(), "template manager not available") { + t.Log("Template manager not available error as expected") + } else { + t.Logf("Got different error: %v", err) + } +} diff --git a/messenger/template/debug_test.go b/messenger/template/debug_test.go new file mode 100644 index 00000000..960ec501 --- /dev/null +++ b/messenger/template/debug_test.go @@ -0,0 +1,47 @@ +package template + +import ( + "testing" + + "github.com/yaoapp/gou/application" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/messenger/types" + "github.com/yaoapp/yao/test" +) + +func TestDebugTemplateLoading(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Test direct template loading + t.Log("Testing direct template loading...") + + // Try to load a specific template file using application.App + content, err := application.App.Read("messengers/templates/en/invite_member.mail.html") + if err != nil { + t.Logf("Could not read template file: %v", err) + } else { + t.Logf("Template file content length: %d", len(content)) + t.Logf("First 200 chars: %s", content[:min(200, len(content))]) + } + + // Test template parsing + subject, body, html, err := parseTemplateContent(string(content), types.TemplateTypeMail) + if err != nil { + t.Logf("Could not parse template: %v", err) + } else { + t.Logf("Parsed template content:") + t.Logf("Subject: %s", subject) + t.Logf("Body length: %d", len(body)) + t.Logf("HTML length: %d", len(html)) + t.Logf("Body content: %s", body) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/messenger/template/load_test.go b/messenger/template/load_test.go new file mode 100644 index 00000000..4c680895 --- /dev/null +++ b/messenger/template/load_test.go @@ -0,0 +1,36 @@ +package template + +import ( + "testing" + + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/share" + "github.com/yaoapp/yao/test" +) + +func TestLoadTemplate(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Test loading a specific template + file := "messengers/templates/en/invite_member.mail.html" + templateID := share.ID("messengers/templates", file) + + t.Logf("Testing loadTemplate with file: %s, templateID: %s", file, templateID) + + template, err := loadTemplate(file, templateID) + if err != nil { + t.Fatalf("Failed to load template: %v", err) + } + + if template == nil { + t.Fatal("Template is nil") + } + + t.Logf("Loaded template: ID=%s, Type=%s, Language=%s", template.ID, template.Type, template.Language) + t.Logf("Subject: %s", template.Subject) + t.Logf("Body length: %d", len(template.Body)) + t.Logf("HTML length: %d", len(template.HTML)) + t.Logf("Body content: %s", template.Body) +} diff --git a/messenger/template/render_test.go b/messenger/template/render_test.go new file mode 100644 index 00000000..4465bf2a --- /dev/null +++ b/messenger/template/render_test.go @@ -0,0 +1,154 @@ +package template + +import ( + "testing" + + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/messenger/types" + "github.com/yaoapp/yao/test" +) + +func TestTemplateRender(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load templates + err := LoadTemplates() + if err != nil { + t.Fatalf("Failed to load templates: %v", err) + } + + // Get template + template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail) + if err != nil { + t.Fatalf("Failed to get template: %v", err) + } + + // Test data + data := types.TemplateData{ + "to": []string{"test@example.com"}, + "team_name": "Awesome Team", + "inviter_name": "Alice Johnson", + "invite_link": "https://example.com/invite/abc123", + } + + // Test rendering + subject, body, html, err := template.Render(data) + if err != nil { + t.Fatalf("Failed to render template: %v", err) + } + + // Verify rendered content + t.Logf("Rendered subject: %s", subject) + t.Logf("Rendered body length: %d", len(body)) + t.Logf("Rendered HTML length: %d", len(html)) + + // Check that variables were replaced + if !contains(subject, "Awesome Team") { + t.Errorf("Subject should contain 'Awesome Team', got: %s", subject) + } + if !contains(body, "Alice Johnson") { + t.Errorf("Body should contain 'Alice Johnson', got: %s", body) + } + if !contains(body, "https://example.com/invite/abc123") { + t.Errorf("Body should contain invite link, got: %s", body) + } +} + +func TestTemplateToMessage(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load templates + err := LoadTemplates() + if err != nil { + t.Fatalf("Failed to load templates: %v", err) + } + + // Get template + template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail) + if err != nil { + t.Fatalf("Failed to get template: %v", err) + } + + // Test data + data := types.TemplateData{ + "to": []string{"test@example.com", "user@example.com"}, + "team_name": "Awesome Team", + "inviter_name": "Alice Johnson", + "invite_link": "https://example.com/invite/abc123", + } + + // Convert template to message + message, err := template.ToMessage(data) + if err != nil { + t.Fatalf("Failed to convert template to message: %v", err) + } + + // Verify message properties + if message.Type != types.MessageType("mail") { + t.Errorf("Expected message type 'mail', got %s", message.Type) + } + if len(message.To) != 2 { + t.Errorf("Expected 2 recipients, got %d", len(message.To)) + } + if !contains(message.Subject, "Awesome Team") { + t.Errorf("Subject should contain 'Awesome Team', got: %s", message.Subject) + } + if !contains(message.Body, "Alice Johnson") { + t.Errorf("Body should contain 'Alice Johnson', got: %s", message.Body) + } + + t.Logf("Generated message: Subject=%s, To=%v", message.Subject, message.To) +} + +func TestNestedTemplateRender(t *testing.T) { + // Test nested object access + template := &types.Template{ + Subject: "Hello {{ user.name }}, welcome to {{ team.name }}!", + Body: "Your role is {{ user.role }} in {{ team.department.name }}.", + } + + data := types.TemplateData{ + "user": map[string]interface{}{ + "name": "John Doe", + "role": "Developer", + }, + "team": map[string]interface{}{ + "name": "Awesome Team", + "department": map[string]interface{}{ + "name": "Engineering", + }, + }, + } + + subject, body, _, err := template.Render(data) + if err != nil { + t.Fatalf("Failed to render template: %v", err) + } + + // Verify nested access works + if !contains(subject, "John Doe") { + t.Errorf("Subject should contain 'John Doe', got: %s", subject) + } + if !contains(subject, "Awesome Team") { + t.Errorf("Subject should contain 'Awesome Team', got: %s", subject) + } + if !contains(body, "Developer") { + t.Errorf("Body should contain 'Developer', got: %s", body) + } + if !contains(body, "Engineering") { + t.Errorf("Body should contain 'Engineering', got: %s", body) + } + + t.Logf("Nested render - Subject: %s", subject) + t.Logf("Nested render - Body: %s", body) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || + contains(s[1:], substr)))) +} diff --git a/messenger/template/template.go b/messenger/template/template.go new file mode 100644 index 00000000..69efc4e8 --- /dev/null +++ b/messenger/template/template.go @@ -0,0 +1,263 @@ +package template + +import ( + "fmt" + "path/filepath" + "strings" + "sync" + + "github.com/PuerkitoBio/goquery" + "github.com/yaoapp/gou/application" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/messenger/types" +) + +// Manager manages message templates +type Manager struct { + templates map[string]map[types.TemplateType]*types.Template // [templateID][type] -> template + mutex sync.RWMutex +} + +// Global template manager instance +var Global *Manager = &Manager{ + templates: make(map[string]map[types.TemplateType]*types.Template), +} + +// GetTemplate returns a template by ID and type +func (m *Manager) GetTemplate(templateID string, templateType types.TemplateType) (*types.Template, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if templates, exists := m.templates[templateID]; exists { + if template, typeExists := templates[templateType]; typeExists { + return template, nil + } + } + return nil, fmt.Errorf("template not found: %s.%s", templateID, templateType) +} + +// GetAllTemplates returns all loaded templates +func (m *Manager) GetAllTemplates() map[string]map[types.TemplateType]*types.Template { + m.mutex.RLock() + defer m.mutex.RUnlock() + + // Return a copy to prevent external modifications + result := make(map[string]map[types.TemplateType]*types.Template) + for id, templates := range m.templates { + result[id] = make(map[types.TemplateType]*types.Template) + for templateType, template := range templates { + result[id][templateType] = template + } + } + return result +} + +// ReloadTemplates reloads all templates from disk +func (m *Manager) ReloadTemplates() error { + m.mutex.Lock() + defer m.mutex.Unlock() + + // Clear existing templates + m.templates = make(map[string]map[types.TemplateType]*types.Template) + + // Load templates from disk + return loadTemplates(m) +} + +// loadTemplates loads all templates from the templates directory +func loadTemplates(m *Manager) error { + // Check if templates directory exists + templatesPath := "messengers/templates" + exists, err := application.App.Exists(templatesPath) + if err != nil { + log.Error("[Template] Error checking templates directory: %v", err) + return err + } + if !exists { + log.Warn("[Template] templates directory not found, skip loading templates") + return nil + } + log.Info("[Template] Templates directory exists, starting to load templates") + + // Walk through template files + // Pattern: {name}.{type}.html and {name}.{type}.txt + exts := []string{"*.mail.html", "*.sms.txt", "*.whatsapp.html"} + log.Info("[Template] Starting to walk templates directory with extensions: %v", exts) + err = application.App.Walk(templatesPath, func(root, file string, isdir bool) error { + log.Info("[Template] Walk callback: root=%s, file=%s, isdir=%v", root, file, isdir) + if isdir { + return nil + } + + log.Info("[Template] Processing file: %s", file) + // Generate template ID manually to avoid share.ID's dot-to-underscore conversion + // Format: {language}.{name} (e.g., "en.invite_member") + relativePath := strings.TrimPrefix(file, root+"/") + pathParts := strings.Split(relativePath, "/") + language := pathParts[0] + filename := pathParts[len(pathParts)-1] + baseName := strings.TrimSuffix(filename, filepath.Ext(filename)) + // Remove type suffix (e.g., "invite_member.mail" -> "invite_member") + templateName := strings.Split(baseName, ".")[0] + templateID := fmt.Sprintf("%s.%s", language, templateName) + + log.Info("[Template] Generated templateID: %s for file: %s", templateID, file) + template, err := loadTemplate(file, templateID) + if err != nil { + log.Warn("[Template] Failed to load template %s: %v", file, err) + return nil // Continue loading other templates + } + + if template != nil { + log.Info("[Template] Loaded template: %s.%s", template.ID, template.Type) + // Initialize template map for this ID if it doesn't exist + if m.templates[template.ID] == nil { + m.templates[template.ID] = make(map[types.TemplateType]*types.Template) + } + m.templates[template.ID][template.Type] = template + } + return nil + }, exts...) + + if err != nil { + return err + } + + log.Info("[Template] Loaded %d templates", len(m.templates)) + return nil +} + +// loadTemplate loads a single template file +func loadTemplate(file string, templateID string) (*types.Template, error) { + raw, err := application.App.Read(file) + if err != nil { + return nil, err + } + + // Extract filename from file path to determine template type + filename := filepath.Base(file) + baseName := strings.TrimSuffix(filename, filepath.Ext(filename)) + + // Parse template type from filename + // Format: {name}.{type}.{ext} -> {type} + templateType, _ := parseTemplateType(baseName) + + // Use the provided templateID (already in format: language.name) + fullTemplateID := templateID + + // Extract language from templateID (format: language.name) + parts := strings.Split(templateID, ".") + language := parts[0] + + // Determine template type + var msgType types.TemplateType + switch templateType { + case "mail": + msgType = types.TemplateTypeMail + case "sms": + msgType = types.TemplateTypeSMS + case "whatsapp": + msgType = types.TemplateTypeWhatsApp + default: + return nil, fmt.Errorf("unsupported template type: %s", templateType) + } + + // Parse template content + subject, body, html, err := parseTemplateContent(string(raw), msgType) + if err != nil { + return nil, err + } + + // No need to compile templates - we'll use simple string replacement + + return &types.Template{ + ID: fullTemplateID, + Type: msgType, + Language: language, + Subject: subject, + Body: body, + HTML: html, + }, nil +} + +// parseTemplateType parses template type from filename +// Example: "invite_member.mail" -> "mail", "invite_member" +func parseTemplateType(filename string) (templateType, templateName string) { + parts := strings.Split(filename, ".") + if len(parts) < 2 { + return "", filename + } + + // Last part is the type + templateType = parts[len(parts)-1] + + // Everything before the last part is the name + templateName = strings.Join(parts[:len(parts)-1], ".") + + return templateType, templateName +} + +// parseTemplateContent parses template content based on type +func parseTemplateContent(content string, templateType types.TemplateType) (subject, body, html string, err error) { + content = strings.TrimSpace(content) + + switch templateType { + case types.TemplateTypeMail: + return parseMailTemplate(content) + case types.TemplateTypeSMS: + return parseSMSTemplate(content) + case types.TemplateTypeWhatsApp: + return parseWhatsAppTemplate(content) + default: + return "", "", "", fmt.Errorf("unsupported template type: %s", templateType) + } +} + +// parseMailTemplate parses mail template with HTML structure using goquery +func parseMailTemplate(content string) (subject, body, html string, err error) { + // Parse HTML content with goquery + doc, err := goquery.NewDocumentFromReader(strings.NewReader(content)) + if err != nil { + return "", "", "", fmt.Errorf("failed to parse mail template HTML: %w", err) + } + + // Extract subject from tag + subject = strings.TrimSpace(doc.Find("Subject").Text()) + + // Extract body content from tag + bodySelection := doc.Find("body") + if bodySelection.Length() == 0 { + return "", "", "", fmt.Errorf("no tag found in mail template") + } + + // Get the HTML content of the body tag + body, err = bodySelection.Html() + if err != nil { + return "", "", "", fmt.Errorf("failed to extract body HTML: %w", err) + } + + body = strings.TrimSpace(body) + + // For mail templates, body is HTML content + html = body + + return subject, body, html, nil +} + +// parseSMSTemplate parses SMS template (plain text) +func parseSMSTemplate(content string) (subject, body, html string, err error) { + // SMS templates are just plain text + body = content + return "", body, "", nil +} + +// parseWhatsAppTemplate parses WhatsApp template (similar to mail) +func parseWhatsAppTemplate(content string) (subject, body, html string, err error) { + // WhatsApp templates use similar structure to mail + return parseMailTemplate(content) +} + +// LoadTemplates loads all templates during messenger initialization +func LoadTemplates() error { + return Global.ReloadTemplates() +} diff --git a/messenger/template/template_test.go b/messenger/template/template_test.go new file mode 100644 index 00000000..28cf8c15 --- /dev/null +++ b/messenger/template/template_test.go @@ -0,0 +1,189 @@ +package template + +import ( + "testing" + + "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" +) + +func TestTemplateManager_LoadTemplates(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load templates + err := LoadTemplates() + require.NoError(t, err) + + // Check if templates were loaded + templates := Global.GetAllTemplates() + assert.NotNil(t, templates) + + // Log loaded templates for debugging + t.Logf("Loaded %d template groups", len(templates)) + for _, templateGroup := range templates { + for _, template := range templateGroup { + t.Logf("Template: %s, Type: %s, Language: %s", template.ID, template.Type, template.Language) + } + } +} + +func TestTemplateManager_GetTemplate(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load templates + err := LoadTemplates() + require.NoError(t, err) + + // Test getting a specific template + template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail) + if err != nil { + t.Logf("Template not found (expected if templates not loaded): %v", err) + return + } + + // Verify template properties + assert.NotNil(t, template) + assert.Equal(t, "en.invite_member", template.ID) + assert.Equal(t, types.TemplateTypeMail, template.Type) + assert.Equal(t, "en", template.Language) + assert.NotEmpty(t, template.Subject) + assert.NotEmpty(t, template.Body) +} + +func TestTemplate_Render(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load templates + err := LoadTemplates() + require.NoError(t, err) + + // Test template rendering + template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail) + if err != nil { + t.Logf("Template not found (expected if templates not loaded): %v", err) + return + } + + // Test data + data := types.TemplateData{ + "team_name": "Awesome Team", + "inviter_name": "Alice Johnson", + "invite_link": "https://example.com/invite/abc123", + } + + // Render template + subject, body, html, err := template.Render(data) + require.NoError(t, err) + + // Verify rendered content + assert.NotEmpty(t, subject) + assert.NotEmpty(t, body) + assert.NotEmpty(t, html) + + // Check that variables were replaced + assert.Contains(t, subject, "Awesome Team") + assert.Contains(t, body, "Alice Johnson") + assert.Contains(t, body, "https://example.com/invite/abc123") + + t.Logf("Rendered subject: %s", subject) + t.Logf("Rendered body: %s", body) +} + +func TestTemplate_ToMessage(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load templates + err := LoadTemplates() + require.NoError(t, err) + + // Test template to message conversion + template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail) + if err != nil { + t.Logf("Template not found (expected if templates not loaded): %v", err) + return + } + + // Test data with recipients + data := types.TemplateData{ + "to": []string{"test@example.com", "user@example.com"}, + "team_name": "Awesome Team", + "inviter_name": "Alice Johnson", + "invite_link": "https://example.com/invite/abc123", + } + + // Convert template to message + message, err := template.ToMessage(data) + require.NoError(t, err) + + // Verify message properties + assert.NotNil(t, message) + assert.Equal(t, types.MessageType("mail"), message.Type) + assert.NotEmpty(t, message.Subject) + assert.NotEmpty(t, message.Body) + assert.NotEmpty(t, message.HTML) + assert.Len(t, message.To, 2) + assert.Equal(t, "test@example.com", message.To[0]) + assert.Equal(t, "user@example.com", message.To[1]) + + // Check that variables were replaced + assert.Contains(t, message.Subject, "Awesome Team") + assert.Contains(t, message.Body, "Alice Johnson") + assert.Contains(t, message.Body, "https://example.com/invite/abc123") + + t.Logf("Generated message: Subject=%s, To=%v", message.Subject, message.To) +} + +func TestTemplate_SMSTemplate(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load templates + err := LoadTemplates() + require.NoError(t, err) + + // Test SMS template + template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeSMS) + if err != nil { + t.Logf("SMS template not found (expected if templates not loaded): %v", err) + return + } + + // Test data + data := types.TemplateData{ + "to": []string{"+1234567890"}, + "team_name": "Awesome Team", + "inviter_name": "Alice Johnson", + "invite_link": "https://example.com/invite/abc123", + } + + // Convert template to message + message, err := template.ToMessage(data) + require.NoError(t, err) + + // Verify SMS message properties + assert.NotNil(t, message) + assert.Equal(t, types.MessageTypeSMS, message.Type) + assert.NotEmpty(t, message.Body) + assert.Empty(t, message.HTML) // SMS should not have HTML + assert.Len(t, message.To, 1) + assert.Equal(t, "+1234567890", message.To[0]) + + // Check that variables were replaced + assert.Contains(t, message.Body, "Alice Johnson") + assert.Contains(t, message.Body, "Awesome Team") + assert.Contains(t, message.Body, "https://example.com/invite/abc123") + + t.Logf("Generated SMS message: Body=%s, To=%v", message.Body, message.To) +} diff --git a/messenger/template/walk_test.go b/messenger/template/walk_test.go new file mode 100644 index 00000000..94c0363b --- /dev/null +++ b/messenger/template/walk_test.go @@ -0,0 +1,49 @@ +package template + +import ( + "testing" + + "github.com/yaoapp/gou/application" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/test" +) + +func TestWalkTemplates(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Test Walk function directly + t.Log("Testing Walk function directly...") + + // Check if templates directory exists + exists, err := application.App.Exists("messengers/templates") + if err != nil { + t.Fatalf("Error checking templates directory: %v", err) + } + if !exists { + t.Log("Templates directory not found") + return + } + + t.Log("Templates directory exists") + + // Test Walk with different extensions + exts := []string{"*.mail.html", "*.sms.txt", "*.whatsapp.html"} + t.Logf("Testing Walk with extensions: %v", exts) + + fileCount := 0 + err = application.App.Walk("messengers/templates", func(root, file string, isdir bool) error { + t.Logf("Walk callback: root=%s, file=%s, isdir=%v", root, file, isdir) + if !isdir { + fileCount++ + } + return nil + }, exts...) + + if err != nil { + t.Fatalf("Walk failed: %v", err) + } + + t.Logf("Walk completed, found %d files", fileCount) +} diff --git a/messenger/types/interfaces.go b/messenger/types/interfaces.go index 3a1912d2..4cc818be 100644 --- a/messenger/types/interfaces.go +++ b/messenger/types/interfaces.go @@ -13,6 +13,15 @@ type Provider interface { // SendBatch sends multiple messages in batch SendBatch(ctx context.Context, messages []*Message) error + // SendT sends a message using a template (optional - providers may return "not implemented" error) + SendT(ctx context.Context, templateID string, data TemplateData) error + + // SendTBatch sends multiple messages using the same template with different data (optional - providers may return "not implemented" error) + SendTBatch(ctx context.Context, templateID string, dataList []TemplateData) error + + // SendTBatchMixed sends multiple messages using different templates with different data (optional - providers may return "not implemented" error) + SendTBatchMixed(ctx context.Context, templateRequests []TemplateRequest) error + // TriggerWebhook processes webhook requests and converts to Message TriggerWebhook(c interface{}) (*Message, error) @@ -40,6 +49,24 @@ type Messenger interface { // SendWithProvider sends a message using a specific provider SendWithProvider(ctx context.Context, providerName string, message *Message) error + // SendT sends a message using a template + SendT(ctx context.Context, channel string, templateID string, data TemplateData) error + + // SendTWithProvider sends a message using a template and specific provider + SendTWithProvider(ctx context.Context, providerName string, templateID string, data TemplateData) error + + // SendTBatch sends multiple messages using the same template with different data + SendTBatch(ctx context.Context, channel string, templateID string, dataList []TemplateData) error + + // SendTBatchWithProvider sends multiple messages using the same template with different data and specific provider + SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []TemplateData) error + + // SendTBatchMixed sends multiple messages using different templates with different data + SendTBatchMixed(ctx context.Context, channel string, templateRequests []TemplateRequest) error + + // SendTBatchMixedWithProvider sends multiple messages using different templates with different data and specific provider + SendTBatchMixedWithProvider(ctx context.Context, providerName string, templateRequests []TemplateRequest) error + // SendBatch sends multiple messages in batch SendBatch(ctx context.Context, channel string, messages []*Message) error diff --git a/messenger/types/template.go b/messenger/types/template.go new file mode 100644 index 00000000..79fe5413 --- /dev/null +++ b/messenger/types/template.go @@ -0,0 +1,146 @@ +package types + +import ( + "fmt" + "regexp" + "strings" +) + +// TemplateType represents the type of template (mail, sms, whatsapp) +type TemplateType string + +const ( + TemplateTypeMail TemplateType = "mail" + TemplateTypeSMS TemplateType = "sms" + TemplateTypeWhatsApp TemplateType = "whatsapp" +) + +// Template represents a message template +type Template struct { + ID string `json:"id"` + Type TemplateType `json:"type"` + Language string `json:"language"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + HTML string `json:"html,omitempty"` +} + +// TemplateData represents data to be used in template rendering +type TemplateData map[string]interface{} + +// Render renders the template with the provided data using simple string replacement +func (t *Template) Render(data TemplateData) (subject, body, html string, err error) { + // Render subject if available + if t.Subject != "" { + subject = renderTemplate(t.Subject, data) + } + + // Render body + if t.Body != "" { + body = renderTemplate(t.Body, data) + } + + // Render HTML if available + if t.HTML != "" { + html = renderTemplate(t.HTML, data) + } + + return subject, body, html, nil +} + +// ToMessage converts template to Message with provided data +func (t *Template) ToMessage(data TemplateData) (*Message, error) { + // Render template + subject, body, html, err := t.Render(data) + if err != nil { + return nil, fmt.Errorf("failed to render template: %w", err) + } + + // Get recipients from data + var recipients []string + if toData, exists := data["to"]; exists { + switch v := toData.(type) { + case []string: + recipients = v + case string: + recipients = []string{v} + default: + return nil, fmt.Errorf("'to' field must be string or []string") + } + } else { + return nil, fmt.Errorf("template data must include 'to' field with recipients") + } + + // Create message + message := &Message{ + Type: MessageType(t.Type), + Subject: subject, + Body: body, + HTML: html, + To: recipients, + } + + // Add optional fields from data + if from, exists := data["from"]; exists { + if fromStr, ok := from.(string); ok { + message.From = fromStr + } + } + + return message, nil +} + +// renderTemplate renders a template string with data using {{ }} syntax +func renderTemplate(template string, data TemplateData) string { + // Find all {{ variable }} patterns + re := regexp.MustCompile(`\{\{\s*([^}]+)\s*\}\}`) + + return re.ReplaceAllStringFunc(template, func(match string) string { + // Extract variable name from {{ variable }} + variable := strings.TrimSpace(strings.Trim(match, "{}")) + + // Get value from data using dot notation for nested access + value := getNestedValue(data, variable) + + // Convert to string + return fmt.Sprintf("%v", value) + }) +} + +// getNestedValue gets a value from data using dot notation (e.g., "user.name", "team.members.count") +func getNestedValue(data TemplateData, key string) interface{} { + parts := strings.Split(key, ".") + + current := interface{}(data) + for _, part := range parts { + part = strings.TrimSpace(part) + + switch v := current.(type) { + case map[string]interface{}: + if val, exists := v[part]; exists { + current = val + } else { + return "" // Return empty string if key not found + } + case TemplateData: + if val, exists := v[part]; exists { + current = val + } else { + return "" // Return empty string if key not found + } + default: + return "" // Return empty string if not a map + } + } + + return current +} + +// TemplateManager manages message templates +type TemplateManager interface { + // GetTemplate returns a template by ID and type + GetTemplate(templateID string, templateType TemplateType) (*Template, error) + + // GetAllTemplates returns all loaded templates + GetAllTemplates() map[string]map[TemplateType]*Template +} diff --git a/messenger/types/types.go b/messenger/types/types.go index f1c0177d..44ea27ba 100644 --- a/messenger/types/types.go +++ b/messenger/types/types.go @@ -80,15 +80,6 @@ type RateLimit struct { Window time.Duration `json:"window,omitempty"` } -// Template represents a message template -type Template struct { - Subject string `json:"subject,omitempty"` - Body string `json:"body"` - HTML string `json:"html,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - // GlobalConfig represents global messenger settings type GlobalConfig struct { RetryAttempts int `json:"retry_attempts,omitempty"` @@ -134,3 +125,9 @@ type Features struct { SupportsTracking bool `json:"supports_tracking"` SupportsScheduling bool `json:"supports_scheduling"` } + +// TemplateRequest represents a request to send a message using a specific template +type TemplateRequest struct { + TemplateID string `json:"template_id"` + Data TemplateData `json:"data"` +}