Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/domain/email_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,11 @@ type SendEmailProviderRequest struct {
Provider *EmailProvider `validate:"required"`
EmailOptions EmailOptions

// PlainText is the optional text/plain alternative body. Empty for templates that
// don't set one (EmailTemplate.Text is nullable) — providers must send HTML-only
// in that case rather than a text/plain part with empty content.
PlainText string

// CapturedMessageID, when non-nil, is written by providers that OVERWRITE the RFC
// Message-ID at send time (e.g. Amazon SES) with the provider-returned MessageId, so
// the caller can store the recipient-visible Message-ID for reply matching. It is a
Expand Down
24 changes: 24 additions & 0 deletions internal/service/email_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,29 @@ func (s *EmailService) SendEmailForTemplate(ctx context.Context, request domain.

htmlContent := *compiledTemplate.HTML

// Plain-text alternative: same Liquid variables as the subject, but unlike the
// subject a rendering failure here must not fail the send — it's a deliverability
// enhancement (multipart/alternative), not the message itself. Fall back to the
// raw, unrendered text so recipients still get readable content instead of losing
// the alternative part entirely.
var plainTextContent string
if emailContent.Text != nil && *emailContent.Text != "" {
processedText, err := notifuse_mjml.ProcessLiquidTemplate(
*emailContent.Text,
request.MessageData.Data,
"email_plain_text",
)
if err != nil {
s.logger.WithFields(map[string]interface{}{
"error": err.Error(),
"message_id": request.MessageID,
"template_id": request.TemplateConfig.TemplateID,
}).Warn("Failed to process plain-text alternative with Liquid templating, sending it unrendered")
processedText = *emailContent.Text
}
plainTextContent = processedText
}

now := time.Now().UTC()

// Convert email options to channel options for storage
Expand Down Expand Up @@ -657,6 +680,7 @@ func (s *EmailService) SendEmailForTemplate(ctx context.Context, request domain.
To: request.Contact.Email,
Subject: subject,
Content: htmlContent,
PlainText: plainTextContent,
Provider: request.EmailProvider,
EmailOptions: request.EmailOptions,
}
Expand Down
147 changes: 147 additions & 0 deletions internal/service/email_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,7 @@ func TestEmailService_SendEmailForTemplate(t *testing.T) {
mockLogger.EXPECT().WithField(gomock.Any(), gomock.Any()).Return(mockLogger).AnyTimes()
mockLogger.EXPECT().Debug(gomock.Any()).AnyTimes()
mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()
mockLogger.EXPECT().Warn(gomock.Any()).AnyTimes()
mockLogger.EXPECT().Error(gomock.Any()).AnyTimes()

// Create email template
Expand Down Expand Up @@ -1168,6 +1169,152 @@ func TestEmailService_SendEmailForTemplate(t *testing.T) {
require.NoError(t, err)
})

t.Run("plain-text alternative is rendered through Liquid and passed to the provider", func(t *testing.T) {
plainText := "Hello {{name}}, visit {{link}}"
emailTemplate.Email.Text = &plainText
defer func() { emailTemplate.Email.Text = nil }()

workspace := &domain.Workspace{
ID: workspaceID,
Settings: domain.WorkspaceSettings{},
}
mockWorkspaceRepo.EXPECT().
GetByID(gomock.Any(), workspaceID).
Return(workspace, nil)
mockTemplateService.EXPECT().
GetTemplateByID(gomock.Any(), workspaceID, templateConfig.TemplateID, int64(0)).
Return(emailTemplate, nil)
mockTemplateService.EXPECT().
CompileTemplate(gomock.Any(), gomock.Any()).
Return(compileResult, nil)
mockMessageRepo.EXPECT().
Create(gomock.Any(), workspaceID, gomock.Any(), gomock.Any()).
Return(nil)

var capturedRequest domain.SendEmailProviderRequest
mockSESService.EXPECT().
SendEmail(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, req domain.SendEmailProviderRequest) error {
capturedRequest = req
return nil
})

request := domain.SendEmailRequest{
WorkspaceID: workspaceID,
IntegrationID: "test-integration-id",
MessageID: messageID,
ExternalID: nil,
Contact: contact,
TemplateConfig: templateConfig,
MessageData: messageData,
TrackingSettings: trackingSettings,
EmailProvider: emailProvider,
EmailOptions: options,
}
err := emailService.SendEmailForTemplate(ctx, request)

require.NoError(t, err)
assert.Equal(t, "Hello Test User, visit https://example.com/test", capturedRequest.PlainText)
})

t.Run("plain-text alternative is left empty when the template has none", func(t *testing.T) {
workspace := &domain.Workspace{
ID: workspaceID,
Settings: domain.WorkspaceSettings{},
}
mockWorkspaceRepo.EXPECT().
GetByID(gomock.Any(), workspaceID).
Return(workspace, nil)
mockTemplateService.EXPECT().
GetTemplateByID(gomock.Any(), workspaceID, templateConfig.TemplateID, int64(0)).
Return(emailTemplate, nil)
mockTemplateService.EXPECT().
CompileTemplate(gomock.Any(), gomock.Any()).
Return(compileResult, nil)
mockMessageRepo.EXPECT().
Create(gomock.Any(), workspaceID, gomock.Any(), gomock.Any()).
Return(nil)

var capturedRequest domain.SendEmailProviderRequest
mockSESService.EXPECT().
SendEmail(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, req domain.SendEmailProviderRequest) error {
capturedRequest = req
return nil
})

request := domain.SendEmailRequest{
WorkspaceID: workspaceID,
IntegrationID: "test-integration-id",
MessageID: messageID,
ExternalID: nil,
Contact: contact,
TemplateConfig: templateConfig,
MessageData: messageData,
TrackingSettings: trackingSettings,
EmailProvider: emailProvider,
EmailOptions: options,
}
err := emailService.SendEmailForTemplate(ctx, request)

require.NoError(t, err)
assert.Empty(t, capturedRequest.PlainText)
})

t.Run("plain-text alternative Liquid error falls back to the raw text instead of failing the send", func(t *testing.T) {
// Oversized Liquid content is a reliable, deterministic way to make
// processLiquidContent error (see SecureLiquidEngine's size limit in
// pkg/notifuse_mjml/liquid_secure_test.go) without depending on undocumented
// parser leniency for malformed syntax.
plainText := "{{name}} " + strings.Repeat("x", 200000)
emailTemplate.Email.Text = &plainText
defer func() { emailTemplate.Email.Text = nil }()

workspace := &domain.Workspace{
ID: workspaceID,
Settings: domain.WorkspaceSettings{},
}
mockWorkspaceRepo.EXPECT().
GetByID(gomock.Any(), workspaceID).
Return(workspace, nil)
mockTemplateService.EXPECT().
GetTemplateByID(gomock.Any(), workspaceID, templateConfig.TemplateID, int64(0)).
Return(emailTemplate, nil)
mockTemplateService.EXPECT().
CompileTemplate(gomock.Any(), gomock.Any()).
Return(compileResult, nil)
mockMessageRepo.EXPECT().
Create(gomock.Any(), workspaceID, gomock.Any(), gomock.Any()).
Return(nil)

var capturedRequest domain.SendEmailProviderRequest
mockSESService.EXPECT().
SendEmail(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, req domain.SendEmailProviderRequest) error {
capturedRequest = req
return nil
})

request := domain.SendEmailRequest{
WorkspaceID: workspaceID,
IntegrationID: "test-integration-id",
MessageID: messageID,
ExternalID: nil,
Contact: contact,
TemplateConfig: templateConfig,
MessageData: messageData,
TrackingSettings: trackingSettings,
EmailProvider: emailProvider,
EmailOptions: options,
}
err := emailService.SendEmailForTemplate(ctx, request)

// The send must still succeed — an unrenderable plain-text alternative is a
// deliverability nice-to-have, not a reason to drop the message.
require.NoError(t, err)
assert.Equal(t, plainText, capturedRequest.PlainText)
})

t.Run("TrackingMode survives the compile-request rebuild", func(t *testing.T) {
workspace := &domain.Workspace{
ID: workspaceID,
Expand Down
8 changes: 8 additions & 0 deletions internal/service/mailgun_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,9 @@ func (s *MailgunService) sendEmailSimple(ctx context.Context, apiURL string, req
form.Add("to", request.To)
form.Add("subject", request.Subject)
form.Add("html", request.Content)
if request.PlainText != "" {
form.Add("text", request.PlainText)
}

// Add cc recipients if provided
for _, ccAddress := range request.EmailOptions.CC {
Expand Down Expand Up @@ -860,6 +863,11 @@ func (s *MailgunService) sendEmailWithAttachments(ctx context.Context, apiURL st
if err := writer.WriteField("html", request.Content); err != nil {
return fmt.Errorf("failed to write html field: %w", err)
}
if request.PlainText != "" {
if err := writer.WriteField("text", request.PlainText); err != nil {
return fmt.Errorf("failed to write text field: %w", err)
}
}

// Add cc recipients if provided
for _, ccAddress := range request.EmailOptions.CC {
Expand Down
112 changes: 112 additions & 0 deletions internal/service/mailgun_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,54 @@ func TestMailgunService_SendEmail(t *testing.T) {
require.NoError(t, err)
})

t.Run("with plain text alternative", func(t *testing.T) {
ctx := context.Background()
plainText := "Test Email Content"

provider := &domain.EmailProvider{
Mailgun: &domain.MailgunSettings{
Domain: "example.com",
APIKey: "test-api-key",
Region: "US",
},
}

resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"id": "<message-id>", "message": "Queued. Thank you."}`)),
}

mockHTTPClient.EXPECT().
Do(gomock.Any()).
DoAndReturn(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
formData := string(body)

assert.Contains(t, formData, "html="+url.QueryEscape(content))
assert.Contains(t, formData, "text="+url.QueryEscape(plainText))

return resp, nil
})

request := domain.SendEmailProviderRequest{
WorkspaceID: workspaceID,
IntegrationID: "test-integration-id",
MessageID: "test-message-id",
FromAddress: fromAddress,
FromName: fromName,
To: to,
Subject: subject,
Content: content,
PlainText: plainText,
Provider: provider,
EmailOptions: domain.EmailOptions{},
}
err := service.SendEmail(ctx, request)

require.NoError(t, err)
})

t.Run("EU region", func(t *testing.T) {
ctx := context.Background()

Expand Down Expand Up @@ -1034,6 +1082,70 @@ func TestMailgunService_SendEmail(t *testing.T) {
require.NoError(t, err)
})

t.Run("with plain text alternative and attachment", func(t *testing.T) {
ctx := context.Background()
plainText := "Test Email Content"

provider := &domain.EmailProvider{
Mailgun: &domain.MailgunSettings{
Domain: "example.com",
APIKey: "test-api-key",
Region: "US",
},
}

base64Content := "c2FtcGxlIHBkZiBjb250ZW50" // base64 of "sample pdf content"

resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"id": "<message-id>", "message": "Queued. Thank you."}`)),
}

mockLogger.EXPECT().WithField(gomock.Any(), gomock.Any()).Return(mockLogger).AnyTimes()
mockLogger.EXPECT().Debug(gomock.Any()).AnyTimes()

mockHTTPClient.EXPECT().
Do(gomock.Any()).
DoAndReturn(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
bodyStr := string(body)

assert.Contains(t, bodyStr, `name="html"`)
assert.Contains(t, bodyStr, `name="text"`)
assert.Contains(t, bodyStr, plainText)
assert.Contains(t, bodyStr, "filename=\"invoice.pdf\"")

return resp, nil
})

request := domain.SendEmailProviderRequest{
WorkspaceID: workspaceID,
IntegrationID: "test-integration-id",
MessageID: "test-message-id",
FromAddress: fromAddress,
FromName: fromName,
To: to,
Subject: subject,
Content: content,
PlainText: plainText,
Provider: provider,
EmailOptions: domain.EmailOptions{
Attachments: []domain.Attachment{
{
Filename: "invoice.pdf",
Content: base64Content,
ContentType: "application/pdf",
Disposition: "attachment",
},
},
},
}
err := service.SendEmail(ctx, request)

require.NoError(t, err)
})

t.Run("email with multiple attachments", func(t *testing.T) {
ctx := context.Background()

Expand Down
1 change: 1 addition & 0 deletions internal/service/mailjet_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ func (s *MailjetService) SendEmail(ctx context.Context, request domain.SendEmail
},
Subject: request.Subject,
HTMLPart: request.Content,
TextPart: request.PlainText,
CustomID: request.MessageID,
}

Expand Down
Loading