From 03ecf96b51217c01b0f83a8beadc619a2a1fac26 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 9 Oct 2025 12:11:54 +0800 Subject: [PATCH] Enhance messenger service with optional message type support for template sending - Updated SendT, SendTWithProvider, SendTBatch, and SendTBatchMixed methods to accept an optional message type parameter, allowing for more flexible template usage. - Refactored provider implementations for Mailgun, Twilio, and Mailer to accommodate the new message type parameter in their SendT and SendTBatch methods. - Improved error handling for template retrieval and provider configuration, ensuring clearer feedback in case of issues. - Added tests to validate the new functionality and ensure compatibility across different message types. --- messenger/messenger.go | 339 ++++++++++++++++-- messenger/messenger_sendt_test.go | 252 +++++++++++++ messenger/providers/mailer/mailer.go | 12 +- .../providers/mailer/mailer_batch_test.go | 4 +- .../providers/mailer/mailer_template_test.go | 6 +- messenger/providers/mailgun/mailgun.go | 12 +- .../providers/mailgun/mailgun_batch_test.go | 4 +- .../mailgun/mailgun_template_test.go | 4 +- messenger/providers/twilio/twilio.go | 12 +- .../providers/twilio/twilio_batch_test.go | 4 +- .../providers/twilio/twilio_template_test.go | 4 +- messenger/template/template.go | 35 +- messenger/types/interfaces.go | 25 +- messenger/types/template.go | 19 +- messenger/types/types.go | 5 +- openapi/user/config.go | 9 +- openapi/user/invitation.go | 211 ++++++++--- openapi/user/team.go | 4 + openapi/user/types.go | 2 + 19 files changed, 839 insertions(+), 124 deletions(-) create mode 100644 messenger/messenger_sendt_test.go diff --git a/messenger/messenger.go b/messenger/messenger.go index e48cbb0b..7f82c042 100644 --- a/messenger/messenger.go +++ b/messenger/messenger.go @@ -300,69 +300,305 @@ func (m *Service) SendWithProvider(ctx context.Context, providerName string, mes } // 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) +// messageType is optional - if not specified, the first available template type will be used +func (m *Service) SendT(ctx context.Context, channel string, templateID string, data types.TemplateData, messageType ...types.MessageType) error { + m.mutex.RLock() + defer m.mutex.RUnlock() + + // Determine which message type to use + var msgType types.MessageType + if len(messageType) > 0 { + // Use specified message type + msgType = messageType[0] + } else { + // Get available template types and use the first one + availableTypes := template.Global.GetAvailableTypes(templateID) + if len(availableTypes) == 0 { + return fmt.Errorf("template not found: %s", templateID) + } + // Convert TemplateType to MessageType + msgType = templateTypeToMessageType(availableTypes[0]) } - // Use the first available provider's SendT method - provider := providers[0] - return provider.SendT(ctx, templateID, data) + // Get provider for this channel and message type + providerName := m.getProviderForChannel(channel, string(msgType)) + if providerName == "" { + return fmt.Errorf("no provider configured for channel %s with message type %s", channel, msgType) + } + + // Get the provider + provider, exists := m.providers[providerName] + if !exists { + return fmt.Errorf("provider not found: %s", providerName) + } + + // Convert MessageType back to TemplateType + templateType := messageTypeToTemplateType(msgType) + + // Call provider's SendT method + return provider.SendT(ctx, templateID, templateType, data) +} + +// templateTypeToMessageType converts TemplateType to MessageType +func templateTypeToMessageType(templateType types.TemplateType) types.MessageType { + switch templateType { + case types.TemplateTypeMail: + return types.MessageTypeEmail + case types.TemplateTypeSMS: + return types.MessageTypeSMS + case types.TemplateTypeWhatsApp: + return types.MessageTypeWhatsApp + default: + return "" + } +} + +// messageTypeToTemplateType converts MessageType to TemplateType +func messageTypeToTemplateType(messageType types.MessageType) types.TemplateType { + switch messageType { + case types.MessageTypeEmail: + return types.TemplateTypeMail + case types.MessageTypeSMS: + return types.TemplateTypeSMS + case types.MessageTypeWhatsApp: + return types.TemplateTypeWhatsApp + default: + return "" + } } // 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 { +// messageType is optional - if not specified, the first available template type will be used +func (m *Service) SendTWithProvider(ctx context.Context, providerName string, templateID string, data types.TemplateData, messageType ...types.MessageType) 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) + // Determine which message type to use + var msgType types.MessageType + if len(messageType) > 0 { + // Use specified message type + msgType = messageType[0] + } else { + // Get available template types and use the first one + availableTypes := template.Global.GetAvailableTypes(templateID) + if len(availableTypes) == 0 { + return fmt.Errorf("template not found: %s", templateID) + } + // Convert TemplateType to MessageType + msgType = templateTypeToMessageType(availableTypes[0]) + } + + // Convert MessageType to TemplateType + templateType := messageTypeToTemplateType(msgType) + + // Call provider's SendT method + return provider.SendT(ctx, templateID, templateType, 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) +// messageType is optional - if not specified, the first available template type will be used +func (m *Service) SendTBatch(ctx context.Context, channel string, templateID string, dataList []types.TemplateData, messageType ...types.MessageType) error { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if len(dataList) == 0 { + return nil } - // Use the first available provider's SendTBatch method - provider := providers[0] - return provider.SendTBatch(ctx, templateID, dataList) + // Determine which message type to use + var msgType types.MessageType + if len(messageType) > 0 { + // Use specified message type + msgType = messageType[0] + } else { + // Get available template types and use the first one + availableTypes := template.Global.GetAvailableTypes(templateID) + if len(availableTypes) == 0 { + return fmt.Errorf("template not found: %s", templateID) + } + // Convert TemplateType to MessageType + msgType = templateTypeToMessageType(availableTypes[0]) + } + + // Get provider for this channel and message type + providerName := m.getProviderForChannel(channel, string(msgType)) + if providerName == "" { + return fmt.Errorf("no provider configured for channel %s with message type %s", channel, msgType) + } + + // Get the provider + provider, exists := m.providers[providerName] + if !exists { + return fmt.Errorf("provider not found: %s", providerName) + } + + // Convert MessageType to TemplateType + templateType := messageTypeToTemplateType(msgType) + + // Get the template + tmpl, err := template.Global.GetTemplate(templateID, templateType) + if err != nil { + return fmt.Errorf("template not found: %w", err) + } + + // Convert all template data to messages + messages := make([]*types.Message, 0, len(dataList)) + for _, data := range dataList { + message, err := tmpl.ToMessage(data) + if err != nil { + return fmt.Errorf("failed to convert template to message: %w", err) + } + messages = append(messages, message) + } + + // Send batch using the provider + return provider.SendBatch(ctx, messages) } // 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 { +// messageType is optional - if not specified, the first available template type will be used +func (m *Service) SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []types.TemplateData, messageType ...types.MessageType) 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) + if len(dataList) == 0 { + return nil + } + + // Determine which message type to use + var msgType types.MessageType + if len(messageType) > 0 { + // Use specified message type + msgType = messageType[0] + } else { + // Get available template types and use the first one + availableTypes := template.Global.GetAvailableTypes(templateID) + if len(availableTypes) == 0 { + return fmt.Errorf("template not found: %s", templateID) + } + // Convert TemplateType to MessageType + msgType = templateTypeToMessageType(availableTypes[0]) + } + + // Convert MessageType to TemplateType + templateType := messageTypeToTemplateType(msgType) + + // Get the template + tmpl, err := template.Global.GetTemplate(templateID, templateType) + if err != nil { + return fmt.Errorf("template not found: %w", err) + } + + // Convert all template data to messages + messages := make([]*types.Message, 0, len(dataList)) + for _, data := range dataList { + message, err := tmpl.ToMessage(data) + if err != nil { + return fmt.Errorf("failed to convert template to message: %w", err) + } + messages = append(messages, message) + } + + // Send batch using the provider + return provider.SendBatch(ctx, messages) } // SendTBatchMixed sends multiple messages using different templates with different data +// Each TemplateRequest can optionally specify its MessageType 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) + m.mutex.RLock() + defer m.mutex.RUnlock() + + if len(templateRequests) == 0 { + return nil } - // Use the first available provider's SendTBatchMixed method - provider := providers[0] - return provider.SendTBatchMixed(ctx, templateRequests) + // Group messages by provider + providerMessages := make(map[string][]*types.Message) + + // Process each template request + for _, request := range templateRequests { + // Determine message type + var msgType types.MessageType + if request.MessageType != nil { + // Use specified message type + msgType = *request.MessageType + } else { + // Get available template types and use the first one + availableTypes := template.Global.GetAvailableTypes(request.TemplateID) + if len(availableTypes) == 0 { + return fmt.Errorf("template not found: %s", request.TemplateID) + } + // Convert TemplateType to MessageType + msgType = templateTypeToMessageType(availableTypes[0]) + } + + // Get provider for this channel and message type + providerName := m.getProviderForChannel(channel, string(msgType)) + if providerName == "" { + return fmt.Errorf("no provider configured for channel %s with message type %s", channel, msgType) + } + + // Verify provider exists + if _, exists := m.providers[providerName]; !exists { + return fmt.Errorf("provider not found: %s", providerName) + } + + // Convert MessageType to TemplateType + templateType := messageTypeToTemplateType(msgType) + + // Get the template + tmpl, err := template.Global.GetTemplate(request.TemplateID, templateType) + if err != nil { + return fmt.Errorf("template %s not found: %w", request.TemplateID, err) + } + + // Convert template to message + message, err := tmpl.ToMessage(request.Data) + if err != nil { + return fmt.Errorf("failed to convert template %s to message: %w", request.TemplateID, err) + } + + // Add to provider's message list + providerMessages[providerName] = append(providerMessages[providerName], message) + } + + // Send batches to each provider + var errors []string + for providerName, messages := range providerMessages { + // Check if context is cancelled + select { + case <-ctx.Done(): + return fmt.Errorf("batch send cancelled: %w", ctx.Err()) + default: + } + + provider, exists := m.providers[providerName] + if !exists { + errors = append(errors, fmt.Sprintf("provider not found: %s", providerName)) + continue + } + + err := provider.SendBatch(ctx, messages) + if err != nil { + errors = append(errors, fmt.Sprintf("provider %s: %v", providerName, err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("batch send errors: %s", strings.Join(errors, "; ")) + } + return nil } // SendTBatchMixedWithProvider sends multiple messages using different templates with different data and specific provider +// Each TemplateRequest can optionally specify its MessageType func (m *Service) SendTBatchMixedWithProvider(ctx context.Context, providerName string, templateRequests []types.TemplateRequest) error { // Get provider provider, exists := m.providers[providerName] @@ -370,8 +606,49 @@ func (m *Service) SendTBatchMixedWithProvider(ctx context.Context, providerName return fmt.Errorf("provider not found: %s", providerName) } - // Use the provider's SendTBatchMixed method directly - return provider.SendTBatchMixed(ctx, templateRequests) + if len(templateRequests) == 0 { + return nil + } + + // Convert all template requests to messages + messages := make([]*types.Message, 0, len(templateRequests)) + + for _, request := range templateRequests { + // Determine message type + var msgType types.MessageType + if request.MessageType != nil { + // Use specified message type + msgType = *request.MessageType + } else { + // Get available template types and use the first one + availableTypes := template.Global.GetAvailableTypes(request.TemplateID) + if len(availableTypes) == 0 { + return fmt.Errorf("template not found: %s", request.TemplateID) + } + // Convert TemplateType to MessageType + msgType = templateTypeToMessageType(availableTypes[0]) + } + + // Convert MessageType to TemplateType + templateType := messageTypeToTemplateType(msgType) + + // Get the template + tmpl, err := template.Global.GetTemplate(request.TemplateID, templateType) + if err != nil { + return fmt.Errorf("template %s not found: %w", request.TemplateID, err) + } + + // Convert template to message + message, err := tmpl.ToMessage(request.Data) + if err != nil { + return fmt.Errorf("failed to convert template %s to message: %w", request.TemplateID, err) + } + + messages = append(messages, message) + } + + // Send batch using the provider + return provider.SendBatch(ctx, messages) } // SendBatch sends multiple messages in batch diff --git a/messenger/messenger_sendt_test.go b/messenger/messenger_sendt_test.go new file mode 100644 index 00000000..1db60225 --- /dev/null +++ b/messenger/messenger_sendt_test.go @@ -0,0 +1,252 @@ +package messenger + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/messenger/template" + "github.com/yaoapp/yao/messenger/types" + "github.com/yaoapp/yao/test" +) + +// TestSendT_TemplateTypeSelection tests that SendT correctly selects template type and provider based on channel configuration +func TestSendT_TemplateTypeSelection(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load messenger + err := Load(config.Conf) + require.NoError(t, err, "Load messenger should succeed") + + // Load templates + err = template.LoadTemplates() + require.NoError(t, err, "Load templates should succeed") + + service, ok := Instance.(*Service) + require.True(t, ok, "Instance should be of type *Service") + + // Test 1: Get available types for a template + availableTypes := template.Global.GetAvailableTypes("en.invite_member") + t.Logf("Available types for en.invite_member: %v", availableTypes) + + // Test 2: SendT without specifying messageType (should use first available) + ctx := context.Background() + data := types.TemplateData{ + "to": []string{"test@example.com"}, + "team_name": "Test Team", + "inviter_name": "Test User", + "invite_link": "https://example.com/invite/test", + } + + // Note: This will fail with actual sending due to test credentials, but we're testing the logic + err = service.SendT(ctx, "default", "en.invite_member", data) + if err != nil { + t.Logf("SendT failed (expected with test credentials): %v", err) + // Should not be template-not-found or provider-not-found error + assert.NotContains(t, err.Error(), "template not found", "Should not be template error") + } + + // Test 3: SendT with explicit messageType + err = service.SendT(ctx, "default", "en.invite_member", data, types.MessageTypeEmail) + if err != nil { + t.Logf("SendT with explicit type failed (expected with test credentials): %v", err) + assert.NotContains(t, err.Error(), "template not found", "Should not be template error") + } +} + +// TestGetProviderForChannel tests the provider selection logic +func TestGetProviderForChannel(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load messenger + err := Load(config.Conf) + require.NoError(t, err, "Load messenger should succeed") + + service, ok := Instance.(*Service) + require.True(t, ok, "Instance should be of type *Service") + + // Test provider selection for different channels and message types + tests := []struct { + channel string + messageType string + expected string // Expected provider name + }{ + {"default", "email", "primary"}, + {"default", "sms", "unified"}, + {"default", "whatsapp", "unified"}, + {"promotions", "email", "marketing"}, + {"alerts", "email", "reliable"}, + {"notifications", "email", "primary"}, + } + + for _, tt := range tests { + t.Run(tt.channel+"_"+tt.messageType, func(t *testing.T) { + providerName := service.getProviderForChannel(tt.channel, tt.messageType) + assert.Equal(t, tt.expected, providerName, + "Channel %s with message type %s should use provider %s", + tt.channel, tt.messageType, tt.expected) + }) + } +} + +// TestTemplateTypeConversion tests the conversion between MessageType and TemplateType +func TestTemplateTypeConversion(t *testing.T) { + // Test templateTypeToMessageType + tests := []struct { + templateType types.TemplateType + expected types.MessageType + }{ + {types.TemplateTypeMail, types.MessageTypeEmail}, + {types.TemplateTypeSMS, types.MessageTypeSMS}, + {types.TemplateTypeWhatsApp, types.MessageTypeWhatsApp}, + } + + for _, tt := range tests { + result := templateTypeToMessageType(tt.templateType) + assert.Equal(t, tt.expected, result, + "TemplateType %s should convert to MessageType %s", + tt.templateType, tt.expected) + } + + // Test messageTypeToTemplateType + reverseTests := []struct { + messageType types.MessageType + expected types.TemplateType + }{ + {types.MessageTypeEmail, types.TemplateTypeMail}, + {types.MessageTypeSMS, types.TemplateTypeSMS}, + {types.MessageTypeWhatsApp, types.TemplateTypeWhatsApp}, + } + + for _, tt := range reverseTests { + result := messageTypeToTemplateType(tt.messageType) + assert.Equal(t, tt.expected, result, + "MessageType %s should convert to TemplateType %s", + tt.messageType, tt.expected) + } +} + +// TestSendTBatch tests the batch sending with template type selection +func TestSendTBatch(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load messenger + err := Load(config.Conf) + require.NoError(t, err, "Load messenger should succeed") + + // Load templates + err = template.LoadTemplates() + require.NoError(t, err, "Load templates should succeed") + + service, ok := Instance.(*Service) + require.True(t, ok, "Instance should be of type *Service") + + // Test batch sending + ctx := context.Background() + dataList := []types.TemplateData{ + { + "to": []string{"user1@example.com"}, + "team_name": "Test Team", + "inviter_name": "Test User", + "invite_link": "https://example.com/invite/test1", + }, + { + "to": []string{"user2@example.com"}, + "team_name": "Test Team", + "inviter_name": "Test User", + "invite_link": "https://example.com/invite/test2", + }, + } + + // Test without explicit messageType + err = service.SendTBatch(ctx, "default", "en.invite_member", dataList) + if err != nil { + t.Logf("SendTBatch failed (expected with test credentials): %v", err) + assert.NotContains(t, err.Error(), "template not found", "Should not be template error") + } + + // Test with explicit messageType + err = service.SendTBatch(ctx, "default", "en.invite_member", dataList, types.MessageTypeEmail) + if err != nil { + t.Logf("SendTBatch with explicit type failed (expected with test credentials): %v", err) + assert.NotContains(t, err.Error(), "template not found", "Should not be template error") + } +} + +// TestSendTBatchMixed tests mixed template batch sending +func TestSendTBatchMixed(t *testing.T) { + // Prepare test environment + test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION") + defer test.Clean() + + // Load messenger + err := Load(config.Conf) + require.NoError(t, err, "Load messenger should succeed") + + // Load templates + err = template.LoadTemplates() + require.NoError(t, err, "Load templates should succeed") + + service, ok := Instance.(*Service) + require.True(t, ok, "Instance should be of type *Service") + + // Test mixed batch sending + ctx := context.Background() + + // Test without specifying MessageType in requests + requests := []types.TemplateRequest{ + { + TemplateID: "en.invite_member", + Data: types.TemplateData{ + "to": []string{"user1@example.com"}, + "team_name": "Test Team", + "inviter_name": "Test User", + "invite_link": "https://example.com/invite/test1", + }, + }, + { + TemplateID: "en.invite_member", + Data: types.TemplateData{ + "to": []string{"user2@example.com"}, + "team_name": "Test Team", + "inviter_name": "Test User", + "invite_link": "https://example.com/invite/test2", + }, + }, + } + + err = service.SendTBatchMixed(ctx, "default", requests) + if err != nil { + t.Logf("SendTBatchMixed failed (expected with test credentials): %v", err) + assert.NotContains(t, err.Error(), "template not found", "Should not be template error") + } + + // Test with explicit MessageType in requests + emailType := types.MessageTypeEmail + requestsWithType := []types.TemplateRequest{ + { + TemplateID: "en.invite_member", + MessageType: &emailType, + Data: types.TemplateData{ + "to": []string{"user1@example.com"}, + "team_name": "Test Team", + "inviter_name": "Test User", + "invite_link": "https://example.com/invite/test1", + }, + }, + } + + err = service.SendTBatchMixed(ctx, "default", requestsWithType) + if err != nil { + t.Logf("SendTBatchMixed with explicit type failed (expected with test credentials): %v", err) + assert.NotContains(t, err.Error(), "template not found", "Should not be template error") + } +} diff --git a/messenger/providers/mailer/mailer.go b/messenger/providers/mailer/mailer.go index 27e532a6..b9e6a1f9 100644 --- a/messenger/providers/mailer/mailer.go +++ b/messenger/providers/mailer/mailer.go @@ -210,9 +210,9 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err } // 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) +func (p *Provider) SendT(ctx context.Context, templateID string, templateType types.TemplateType, data types.TemplateData) error { + // Get template from provider's template manager with specified type + template, err := p.getTemplate(templateID, templateType) if err != nil { return fmt.Errorf("template not found: %w", err) } @@ -228,9 +228,9 @@ func (p *Provider) SendT(ctx context.Context, templateID string, data types.Temp } // 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) +func (p *Provider) SendTBatch(ctx context.Context, templateID string, templateType types.TemplateType, dataList []types.TemplateData) error { + // Get template from provider's template manager with specified type + template, err := p.getTemplate(templateID, templateType) if err != nil { return fmt.Errorf("template not found: %w", err) } diff --git a/messenger/providers/mailer/mailer_batch_test.go b/messenger/providers/mailer/mailer_batch_test.go index 05b801c7..ebcdc2ca 100644 --- a/messenger/providers/mailer/mailer_batch_test.go +++ b/messenger/providers/mailer/mailer_batch_test.go @@ -50,7 +50,7 @@ func TestSendTBatch_Success(t *testing.T) { } // Test SendTBatch - should fail because template manager is nil - err = provider.SendTBatch(context.Background(), "en.invite_member", dataList) + err = provider.SendTBatch(context.Background(), "en.invite_member", types.TemplateTypeMail, dataList) assert.Error(t, err) assert.Contains(t, err.Error(), "template manager not available") } @@ -95,7 +95,7 @@ func TestSendTBatch_ContextTimeout(t *testing.T) { time.Sleep(2 * time.Nanosecond) // Test SendTBatch with expired context - err = provider.SendTBatch(ctx, "en.invite_member", dataList) + err = provider.SendTBatch(ctx, "en.invite_member", types.TemplateTypeMail, dataList) 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 index c064480f..ba9fa1a7 100644 --- a/messenger/providers/mailer/mailer_template_test.go +++ b/messenger/providers/mailer/mailer_template_test.go @@ -48,7 +48,7 @@ func TestSendT_TemplateNotImplemented(t *testing.T) { } // Test that SendT returns "template not found" error (template system is working) - err = provider.SendT(ctx, "en.invite_member.mail", templateData) + err = provider.SendT(ctx, "en.invite_member.mail", types.TemplateTypeMail, templateData) assert.Error(t, err) assert.Contains(t, err.Error(), "template not found") } @@ -88,7 +88,7 @@ func TestSendT_ContextTimeout(t *testing.T) { "invite_link": "https://example.com/invite/123", } - err = provider.SendT(ctx, "en.invite_member.mail", templateData) + err = provider.SendT(ctx, "en.invite_member.mail", types.TemplateTypeMail, templateData) assert.Error(t, err) // Verify it's a context timeout error or not implemented error @@ -173,6 +173,6 @@ func BenchmarkSendT(b *testing.B) { 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) + _ = provider.SendT(ctx, "en.invite_member.mail", types.TemplateTypeMail, templateData) } } diff --git a/messenger/providers/mailgun/mailgun.go b/messenger/providers/mailgun/mailgun.go index a0282350..82d22094 100644 --- a/messenger/providers/mailgun/mailgun.go +++ b/messenger/providers/mailgun/mailgun.go @@ -94,9 +94,9 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err } // 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 +func (p *Provider) SendT(ctx context.Context, templateID string, templateType types.TemplateType, data types.TemplateData) error { + // Get template from provider's template manager with specified type + template, err := p.getTemplate(templateID, templateType) if err != nil { return fmt.Errorf("template not found: %w", err) } @@ -112,9 +112,9 @@ func (p *Provider) SendT(ctx context.Context, templateID string, data types.Temp } // 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 +func (p *Provider) SendTBatch(ctx context.Context, templateID string, templateType types.TemplateType, dataList []types.TemplateData) error { + // Get template from provider's template manager with specified type + template, err := p.getTemplate(templateID, templateType) if err != nil { return fmt.Errorf("template not found: %w", err) } diff --git a/messenger/providers/mailgun/mailgun_batch_test.go b/messenger/providers/mailgun/mailgun_batch_test.go index a1b040c6..36e896e8 100644 --- a/messenger/providers/mailgun/mailgun_batch_test.go +++ b/messenger/providers/mailgun/mailgun_batch_test.go @@ -46,7 +46,7 @@ func TestSendTBatch_Success(t *testing.T) { } // Test SendTBatch - should fail because template manager is nil - err = provider.SendTBatch(context.Background(), "en.invite_member", dataList) + err = provider.SendTBatch(context.Background(), "en.invite_member", types.TemplateTypeMail, dataList) assert.Error(t, err) assert.Contains(t, err.Error(), "template manager not available") } @@ -87,7 +87,7 @@ func TestSendTBatch_ContextTimeout(t *testing.T) { time.Sleep(2 * time.Nanosecond) // Test SendTBatch with expired context - err = provider.SendTBatch(ctx, "en.invite_member", dataList) + err = provider.SendTBatch(ctx, "en.invite_member", types.TemplateTypeMail, dataList) 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 index 1bf43eb8..fdc685e9 100644 --- a/messenger/providers/mailgun/mailgun_template_test.go +++ b/messenger/providers/mailgun/mailgun_template_test.go @@ -41,7 +41,7 @@ func TestSendT_TemplateNotImplemented(t *testing.T) { } // Test that SendT returns "template manager not available" error - err = provider.SendT(ctx, "en.invite_member", templateData) + err = provider.SendT(ctx, "en.invite_member", types.TemplateTypeMail, templateData) assert.Error(t, err) assert.Contains(t, err.Error(), "template manager not available") } @@ -80,7 +80,7 @@ func TestSendT_ContextTimeout(t *testing.T) { } // Test that SendT handles context timeout - err = provider.SendT(ctx, "en.invite_member", templateData) + err = provider.SendT(ctx, "en.invite_member", types.TemplateTypeMail, templateData) assert.Error(t, err) // Verify it's a context timeout error or template manager error diff --git a/messenger/providers/twilio/twilio.go b/messenger/providers/twilio/twilio.go index 5d1bec79..60644490 100644 --- a/messenger/providers/twilio/twilio.go +++ b/messenger/providers/twilio/twilio.go @@ -126,9 +126,9 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err } // 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 +func (p *Provider) SendT(ctx context.Context, templateID string, templateType types.TemplateType, data types.TemplateData) error { + // Get template from provider's template manager with specified type + template, err := p.getTemplate(templateID, templateType) if err != nil { return fmt.Errorf("template not found: %w", err) } @@ -144,9 +144,9 @@ func (p *Provider) SendT(ctx context.Context, templateID string, data types.Temp } // 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 +func (p *Provider) SendTBatch(ctx context.Context, templateID string, templateType types.TemplateType, dataList []types.TemplateData) error { + // Get template from provider's template manager with specified type + template, err := p.getTemplate(templateID, templateType) if err != nil { return fmt.Errorf("template not found: %w", err) } diff --git a/messenger/providers/twilio/twilio_batch_test.go b/messenger/providers/twilio/twilio_batch_test.go index 44b8134a..5637713c 100644 --- a/messenger/providers/twilio/twilio_batch_test.go +++ b/messenger/providers/twilio/twilio_batch_test.go @@ -46,7 +46,7 @@ func TestSendTBatch_Success(t *testing.T) { } // Test SendTBatch - should fail because template manager is nil - err = provider.SendTBatch(context.Background(), "en.invite_member", dataList) + err = provider.SendTBatch(context.Background(), "en.invite_member", types.TemplateTypeSMS, dataList) assert.Error(t, err) assert.Contains(t, err.Error(), "template manager not available") } @@ -87,7 +87,7 @@ func TestSendTBatch_ContextTimeout(t *testing.T) { time.Sleep(2 * time.Nanosecond) // Test SendTBatch with expired context - err = provider.SendTBatch(ctx, "en.invite_member", dataList) + err = provider.SendTBatch(ctx, "en.invite_member", types.TemplateTypeSMS, dataList) 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 index 001a1958..dec7f53c 100644 --- a/messenger/providers/twilio/twilio_template_test.go +++ b/messenger/providers/twilio/twilio_template_test.go @@ -40,7 +40,7 @@ func TestSendT_TemplateNotImplemented(t *testing.T) { } // Test that SendT returns "template manager not available" error - err = provider.SendT(ctx, "en.invite_member", templateData) + err = provider.SendT(ctx, "en.invite_member", types.TemplateTypeSMS, templateData) assert.Error(t, err) assert.Contains(t, err.Error(), "template manager not available") } @@ -78,7 +78,7 @@ func TestSendT_ContextTimeout(t *testing.T) { } // Test that SendT handles context timeout - err = provider.SendT(ctx, "en.invite_member", templateData) + err = provider.SendT(ctx, "en.invite_member", types.TemplateTypeSMS, templateData) assert.Error(t, err) // Verify it's a context timeout error or template manager error diff --git a/messenger/template/template.go b/messenger/template/template.go index 69efc4e8..3294665c 100644 --- a/messenger/template/template.go +++ b/messenger/template/template.go @@ -52,6 +52,31 @@ func (m *Manager) GetAllTemplates() map[string]map[types.TemplateType]*types.Tem return result } +// GetAvailableTypes returns all available template types for a given templateID +// Returns types in a consistent order: mail, sms, whatsapp +func (m *Manager) GetAvailableTypes(templateID string) []types.TemplateType { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if templates, exists := m.templates[templateID]; exists { + // Return in preferred order + var result []types.TemplateType + preferredOrder := []types.TemplateType{ + types.TemplateTypeMail, + types.TemplateTypeSMS, + types.TemplateTypeWhatsApp, + } + + for _, templateType := range preferredOrder { + if _, exists := templates[templateType]; exists { + result = append(result, templateType) + } + } + return result + } + return []types.TemplateType{} +} + // ReloadTemplates reloads all templates from disk func (m *Manager) ReloadTemplates() error { m.mutex.Lock() @@ -224,16 +249,16 @@ func parseMailTemplate(content string) (subject, body, html string, err error) { // Extract subject from tag subject = strings.TrimSpace(doc.Find("Subject").Text()) - // Extract body content from tag - bodySelection := doc.Find("body") + // Extract body content from tag (using custom tag to avoid HTML parser auto-conversion) + bodySelection := doc.Find("Content") if bodySelection.Length() == 0 { - return "", "", "", fmt.Errorf("no tag found in mail template") + return "", "", "", fmt.Errorf("no tag found in mail template") } - // Get the HTML content of the body tag + // Get the HTML content of the Content tag body, err = bodySelection.Html() if err != nil { - return "", "", "", fmt.Errorf("failed to extract body HTML: %w", err) + return "", "", "", fmt.Errorf("failed to extract content HTML: %w", err) } body = strings.TrimSpace(body) diff --git a/messenger/types/interfaces.go b/messenger/types/interfaces.go index 4cc818be..a4b585ac 100644 --- a/messenger/types/interfaces.go +++ b/messenger/types/interfaces.go @@ -13,13 +13,16 @@ 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 + // SendT sends a message using a template with specified type + // templateType specifies which template variant to use (mail, sms, whatsapp) + SendT(ctx context.Context, templateID string, templateType TemplateType, 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 + // SendTBatch sends multiple messages using the same template with different data + // templateType specifies which template variant to use (mail, sms, whatsapp) + SendTBatch(ctx context.Context, templateID string, templateType TemplateType, dataList []TemplateData) error - // SendTBatchMixed sends multiple messages using different templates with different data (optional - providers may return "not implemented" error) + // SendTBatchMixed sends multiple messages using different templates with different data + // Each TemplateRequest can optionally specify its own MessageType SendTBatchMixed(ctx context.Context, templateRequests []TemplateRequest) error // TriggerWebhook processes webhook requests and converts to Message @@ -50,16 +53,20 @@ type Messenger interface { 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 + // messageType is optional - if not specified, the first available template type will be used + SendT(ctx context.Context, channel string, templateID string, data TemplateData, messageType ...MessageType) error // SendTWithProvider sends a message using a template and specific provider - SendTWithProvider(ctx context.Context, providerName string, templateID string, data TemplateData) error + // messageType is optional - if not specified, the first available template type will be used + SendTWithProvider(ctx context.Context, providerName string, templateID string, data TemplateData, messageType ...MessageType) error // SendTBatch sends multiple messages using the same template with different data - SendTBatch(ctx context.Context, channel string, templateID string, dataList []TemplateData) error + // messageType is optional - if not specified, the first available template type will be used + SendTBatch(ctx context.Context, channel string, templateID string, dataList []TemplateData, messageType ...MessageType) 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 + // messageType is optional - if not specified, the first available template type will be used + SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []TemplateData, messageType ...MessageType) error // SendTBatchMixed sends multiple messages using different templates with different data SendTBatchMixed(ctx context.Context, channel string, templateRequests []TemplateRequest) error diff --git a/messenger/types/template.go b/messenger/types/template.go index 79fe5413..904bcd25 100644 --- a/messenger/types/template.go +++ b/messenger/types/template.go @@ -15,6 +15,20 @@ const ( TemplateTypeWhatsApp TemplateType = "whatsapp" ) +// templateTypeToMessageType converts TemplateType to MessageType +func templateTypeToMessageType(templateType TemplateType) MessageType { + switch templateType { + case TemplateTypeMail: + return MessageTypeEmail + case TemplateTypeSMS: + return MessageTypeSMS + case TemplateTypeWhatsApp: + return MessageTypeWhatsApp + default: + return "" + } +} + // Template represents a message template type Template struct { ID string `json:"id"` @@ -71,9 +85,12 @@ func (t *Template) ToMessage(data TemplateData) (*Message, error) { return nil, fmt.Errorf("template data must include 'to' field with recipients") } + // Convert TemplateType to MessageType + messageType := templateTypeToMessageType(t.Type) + // Create message message := &Message{ - Type: MessageType(t.Type), + Type: messageType, Subject: subject, Body: body, HTML: html, diff --git a/messenger/types/types.go b/messenger/types/types.go index 44ea27ba..22a8cbbe 100644 --- a/messenger/types/types.go +++ b/messenger/types/types.go @@ -128,6 +128,7 @@ type Features struct { // TemplateRequest represents a request to send a message using a specific template type TemplateRequest struct { - TemplateID string `json:"template_id"` - Data TemplateData `json:"data"` + TemplateID string `json:"template_id"` + Data TemplateData `json:"data"` + MessageType *MessageType `json:"message_type,omitempty"` // Optional: if not specified, will use first available template type } diff --git a/openapi/user/config.go b/openapi/user/config.go index 54fd94db..d0bf1dfb 100644 --- a/openapi/user/config.go +++ b/openapi/user/config.go @@ -419,7 +419,7 @@ func GetTeamConfig(locale string) *TeamConfig { // Normalize language code to lowercase if locale != "" { - locale = strings.ToLower(locale) + locale = strings.TrimSpace(strings.ToLower(locale)) } // Try to get the specific locale configuration @@ -427,7 +427,12 @@ func GetTeamConfig(locale string) *TeamConfig { return config } - // If no specific locale, try to get any available configuration + // If no specific locale, try to get "en" as default + if config, exists := teamConfigs["en"]; exists { + return config + } + + // If "en" is not available, try to get any available configuration for _, config := range teamConfigs { return config } diff --git a/openapi/user/invitation.go b/openapi/user/invitation.go index 7916e4f6..2fc2f7bf 100644 --- a/openapi/user/invitation.go +++ b/openapi/user/invitation.go @@ -20,6 +20,7 @@ import ( messengertypes "github.com/yaoapp/yao/messenger/types" "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/share" ) // Team Invitation Management Handlers @@ -118,6 +119,9 @@ func GinInvitationGet(c *gin.Context) { return } + // Extract base URL from request + requestBaseURL := getRequestBaseURL(c) + // Call business logic invitationData, err := invitationGet(c.Request.Context(), authInfo.UserID, teamID, invitationID) if err != nil { @@ -145,8 +149,8 @@ func GinInvitationGet(c *gin.Context) { return } - // Convert to response format - invitation := mapToInvitationDetailResponse(invitationData) + // Convert to response format (with requestBaseURL for building full invitation link) + invitation := mapToInvitationDetailResponse(invitationData, requestBaseURL) c.JSON(http.StatusOK, invitation) } @@ -184,14 +188,18 @@ func GinInvitationCreate(c *gin.Context) { return } + // Extract base URL from request + requestBaseURL := getRequestBaseURL(c) + // Prepare invitation data invitationData := maps.MapStrAny{ - "user_id": req.UserID, - "email": req.Email, - "member_type": req.MemberType, - "role_id": req.RoleID, - "message": req.Message, - "expiry": req.Expiry, + "user_id": req.UserID, + "email": req.Email, + "member_type": req.MemberType, + "role_id": req.RoleID, + "message": req.Message, + "expiry": req.Expiry, + "request_base_url": requestBaseURL, } // Prepare settings @@ -205,6 +213,11 @@ func GinInvitationCreate(c *gin.Context) { settings.SendEmail = *req.SendEmail } + // Add locale from top-level field (for backward compatibility) + if req.Locale != "" { + settings.Locale = req.Locale + } + // Add settings to invitation data if settings.SendEmail || settings.Locale != "" { invitationData["settings"] = settings @@ -258,8 +271,8 @@ func GinInvitationCreate(c *gin.Context) { return } - // Convert to InvitationResponse - invitationResp := convertToInvitationResponse(invitation) + // Convert to InvitationResponse (with requestBaseURL for building full invitation link) + invitationResp := convertToInvitationResponse(invitation, requestBaseURL) // Return created invitation with full details (including token) c.JSON(http.StatusCreated, invitationResp) @@ -290,7 +303,7 @@ func GinInvitationResend(c *gin.Context) { } // Call business logic - err := invitationResend(c.Request.Context(), authInfo.UserID, teamID, invitationID) + err := invitationResend(c.Request.Context(), authInfo.UserID, teamID, invitationID, getRequestBaseURL(c)) if err != nil { log.Error("Failed to resend invitation: %v", err) // Check error type for appropriate response @@ -526,8 +539,8 @@ func ProcessInvitationResend(process *process.Process) interface{} { ctx = context.Background() } - // Call business logic - err := invitationResend(ctx, userIDStr, teamID, invitationID) + // Call business logic (no requestBaseURL available in process context) + err := invitationResend(ctx, userIDStr, teamID, invitationID, "") if err != nil { exception.New("failed to resend invitation: %s", 500, err.Error()).Throw() } @@ -573,6 +586,75 @@ func ProcessInvitationDelete(process *process.Process) interface{} { // Private Business Logic Functions (internal use only) +// getAdminRoot returns the admin root path from share.App configuration +// Similar to service.setupAdminRoot but without caching to avoid circular dependencies +func getAdminRoot() string { + adminRoot := "/yao/" + if share.App.AdminRoot != "" { + root := strings.TrimPrefix(share.App.AdminRoot, "/") + root = strings.TrimSuffix(root, "/") + adminRoot = fmt.Sprintf("/%s/", root) + } + return adminRoot +} + +// getRequestBaseURL extracts the base URL from the gin context request +// Returns: scheme://host (e.g., "https://example.com" or "http://localhost:8000") +func getRequestBaseURL(c *gin.Context) string { + if c == nil || c.Request == nil { + return "" + } + + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + // Check X-Forwarded-Proto header + if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + + host := c.Request.Host + if host == "" { + return "" + } + + return fmt.Sprintf("%s://%s", scheme, host) +} + +// buildInvitationLink constructs a full invitation link from invitation_id, token and team configuration +// This is a centralized function to ensure consistency across email sending and link generation +// Format: +// - With team config baseURL: {base_url}/{invitation_id}/{token} +// - With requestBaseURL (from HTTP request): {scheme}://{host}{AdminRoot}team/invite/{invitation_id}/{token} +// - Without any baseURL (fallback): {AdminRoot}team/invite/{invitation_id}/{token} +func buildInvitationLink(invitationID, token string, teamConfig *TeamConfig, requestBaseURL string) string { + // Priority 1: Use team config baseURL if specified + if teamConfig != nil && teamConfig.Invite != nil && teamConfig.Invite.BaseURL != "" { + baseURL := teamConfig.Invite.BaseURL + // Ensure baseURL ends with / + if !strings.HasSuffix(baseURL, "/") { + baseURL = baseURL + "/" + } + return fmt.Sprintf("%s%s/%s", baseURL, invitationID, token) + } + + // Get admin root from configuration + adminRoot := getAdminRoot() + // Ensure adminRoot doesn't end with / for URL construction + adminRoot = strings.TrimSuffix(adminRoot, "/") + + // Priority 2: Use request baseURL with AdminRoot + if requestBaseURL != "" { + // Ensure requestBaseURL doesn't end with / + requestBaseURL = strings.TrimSuffix(requestBaseURL, "/") + return fmt.Sprintf("%s%s/team/invite/%s/%s", requestBaseURL, adminRoot, invitationID, token) + } + + // Priority 3: Fallback to relative path with AdminRoot + return fmt.Sprintf("%s/team/invite/%s/%s", adminRoot, invitationID, token) +} + // invitationList handles the business logic for listing team invitations func invitationList(ctx context.Context, userID, teamID string, page, pagesize int, status string) (maps.MapStr, error) { // Check if user has access to the team (read permission: owner or member) @@ -762,6 +844,10 @@ func invitationCreate(ctx context.Context, userID, teamID string, invitationData return "", fmt.Errorf("failed to parse expiry duration: %w", err) } + // Save request_base_url and settings before database operation (they will be lost in DB) + requestBaseURL := toString(invitationData["request_base_url"]) + savedSettings := invitationData["settings"] // Save settings reference + // Set invitation-specific fields invitationData["team_id"] = teamID if invitationData["member_type"] == nil || invitationData["member_type"] == "" { @@ -792,21 +878,34 @@ func invitationCreate(ctx context.Context, userID, teamID string, invitationData // Send email if requested (shouldSendEmail was already determined earlier) if shouldSendEmail { - err = sendInvitationEmail(ctx, inviteeEmail, inviterName, teamName, token, invitationID, invitationData) - if err != nil { - log.Error("Failed to send invitation email: %v", err) - // Don't fail the invitation creation if email fails - // The invitation link can still be shared manually - } else { - log.Info("Invitation email sent to %s for team %s (invitation_id: %s)", inviteeEmail, teamName, invitationID) - } + // Use the saved requestBaseURL and settings (not from invitationData, as they were lost in DB operation) + // Send email asynchronously to improve user experience + go func() { + // Use background context for async operation + bgCtx := context.Background() + + // Ensure request_base_url and settings are in invitationData for email sending + emailData := maps.MapStrAny{} + for k, v := range invitationData { + emailData[k] = v + } + emailData["request_base_url"] = requestBaseURL + emailData["settings"] = savedSettings // Restore settings + + err := sendInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, token, invitationID, emailData) + if err != nil { + log.Error("Failed to send invitation email: %v", err) + } else { + log.Info("Invitation email sent to %s for team %s (invitation_id: %s)", inviteeEmail, teamName, invitationID) + } + }() } return invitationID, nil } // invitationResend handles the business logic for resending a team invitation -func invitationResend(ctx context.Context, userID, teamID, invitationID string) error { +func invitationResend(ctx context.Context, userID, teamID, invitationID, requestBaseURL string) error { // Check if user has access to the team (write permission: owner only) isOwner, _, err := checkTeamAccess(ctx, teamID, userID) if err != nil { @@ -889,6 +988,7 @@ func invitationResend(ctx context.Context, userID, teamID, invitationID string) // Update the invitation data for email sending invitationData["invitation_token"] = newToken invitationData["invitation_expires_at"] = newExpiryTime + invitationData["request_base_url"] = requestBaseURL // Get email from invitation data var inviteeEmail string @@ -902,13 +1002,18 @@ func invitationResend(ctx context.Context, userID, teamID, invitationID string) } } - // Send new invitation email if email is available + // Send new invitation email if email is available (asynchronously) if inviteeEmail != "" { - err = sendInvitationEmail(ctx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData) - if err != nil { - log.Error("Failed to resend invitation email: %v", err) - // Don't fail the resend if email fails - } + go func() { + // Use background context for async operation + bgCtx := context.Background() + err := sendInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData) + if err != nil { + log.Error("Failed to resend invitation email: %v", err) + } else { + log.Info("Invitation email resent to %s for team %s (invitation_id: %s)", inviteeEmail, teamName, invitationID) + } + }() } return nil @@ -1044,6 +1149,7 @@ func sendInvitationEmail(ctx context.Context, email, inviterName, teamName, toke } // Get team config for email template and channel + // Note: GetTeamConfig will normalize locale internally (trim, lowercase, etc.) teamConfig := GetTeamConfig(locale) if teamConfig == nil || teamConfig.Invite == nil { return fmt.Errorf("team configuration not found for locale: %s", locale) @@ -1069,35 +1175,41 @@ func sendInvitationEmail(ctx context.Context, email, inviterName, teamName, toke // Get custom message from invitation data customMessage := toString(invitationData["message"]) + // Get request base URL from invitation data (if provided) + requestBaseURL := toString(invitationData["request_base_url"]) + + // Build invitation link using centralized helper function + invitationLink := buildInvitationLink(invitationID, token, teamConfig, requestBaseURL) + // Prepare template data for messenger templateData := messengertypes.TemplateData{ - "to": email, - "inviter_name": inviterName, - "team_name": teamName, - "invitation_id": invitationID, - "token": token, - "message": customMessage, - "role_id": toString(invitationData["role_id"]), - "expires_at": toString(invitationData["invitation_expires_at"]), + "to": email, + "inviter_name": inviterName, + "team_name": teamName, + "invitation_id": invitationID, + "invitation_link": invitationLink, // Full invitation link + "token": token, // Keep token for backward compatibility + "message": customMessage, + "role_id": toString(invitationData["role_id"]), + "expires_at": toString(invitationData["invitation_expires_at"]), } // Send email using messenger template - err := messenger.Instance.SendT(ctx, channel, emailTemplate, templateData) + err := messenger.Instance.SendT(ctx, channel, emailTemplate, templateData, messengertypes.MessageTypeEmail) if err != nil { return fmt.Errorf("failed to send invitation email: %w", err) } - log.Info("Invitation email sent to %s for team %s (invitation_id: %s)", email, teamName, invitationID) return nil } // convertToInvitationResponse converts a map to InvitationResponse (alias for mapToInvitationResponse) -func convertToInvitationResponse(data maps.MapStrAny) InvitationResponse { - return mapToInvitationResponse(maps.MapStr(data)) +func convertToInvitationResponse(data maps.MapStrAny, requestBaseURL string) InvitationResponse { + return mapToInvitationResponse(maps.MapStr(data), requestBaseURL) } // mapToInvitationResponse converts a map to InvitationResponse -func mapToInvitationResponse(data maps.MapStr) InvitationResponse { +func mapToInvitationResponse(data maps.MapStr, requestBaseURL string) InvitationResponse { invitation := InvitationResponse{ ID: toInt64(data["id"]), InvitationID: toString(data["invitation_id"]), @@ -1116,9 +1228,13 @@ func mapToInvitationResponse(data maps.MapStr) InvitationResponse { } // Add settings if available + locale := "en" // Default locale if settings, ok := data["settings"]; ok { if invSettings, ok := settings.(*InvitationSettings); ok { invitation.Settings = invSettings + if invSettings.Locale != "" { + locale = invSettings.Locale + } } else if settingsMap, ok := settings.(map[string]interface{}); ok { // Convert map to InvitationSettings invSettings := &InvitationSettings{ @@ -1126,16 +1242,25 @@ func mapToInvitationResponse(data maps.MapStr) InvitationResponse { Locale: toString(settingsMap["locale"]), } invitation.Settings = invSettings + if invSettings.Locale != "" { + locale = invSettings.Locale + } } } + // Build invitation link if token is available + if invitation.InvitationToken != "" && invitation.InvitationID != "" { + teamConfig := GetTeamConfig(locale) + invitation.InvitationLink = buildInvitationLink(invitation.InvitationID, invitation.InvitationToken, teamConfig, requestBaseURL) + } + return invitation } // mapToInvitationDetailResponse converts a map to InvitationDetailResponse -func mapToInvitationDetailResponse(data maps.MapStr) InvitationDetailResponse { +func mapToInvitationDetailResponse(data maps.MapStr, requestBaseURL string) InvitationDetailResponse { invitation := InvitationDetailResponse{ - InvitationResponse: mapToInvitationResponse(data), + InvitationResponse: mapToInvitationResponse(data, requestBaseURL), } // Add user info if available (could be joined from user table) diff --git a/openapi/user/team.go b/openapi/user/team.go index 0f8edb86..9d371c4f 100644 --- a/openapi/user/team.go +++ b/openapi/user/team.go @@ -29,6 +29,10 @@ func GinTeamConfig(c *gin.Context) { locale = "en" // default locale } + // Clean locale: remove whitespace and special characters + locale = strings.TrimSpace(locale) + locale = strings.Trim(locale, "?&=") + config := GetTeamConfig(locale) if config == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Team configuration not found"}) diff --git a/openapi/user/types.go b/openapi/user/types.go index afcd63c7..7e9f1f2a 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -315,6 +315,7 @@ type InvitationResponse struct { InvitedBy string `json:"invited_by"` InvitedAt string `json:"invited_at"` InvitationToken string `json:"invitation_token,omitempty"` + InvitationLink string `json:"invitation_link,omitempty"` // Full invitation link InvitationExpiresAt string `json:"invitation_expires_at,omitempty"` Message string `json:"message,omitempty"` Settings *InvitationSettings `json:"settings,omitempty"` @@ -339,6 +340,7 @@ type CreateInvitationRequest struct { Message string `json:"message,omitempty"` Expiry string `json:"expiry,omitempty"` // Custom expiry duration (e.g., "1d", "8h"), defaults to team config SendEmail *bool `json:"send_email,omitempty"` // Whether to send email (defaults to false) + Locale string `json:"locale,omitempty"` // Language code for email template (e.g., "zh-CN", "en") Settings *InvitationSettings `json:"settings,omitempty"` }