fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed

This commit is contained in:
Stephan D
2025-11-08 00:40:01 +01:00
parent 49b86efecb
commit d367dddbbd
98 changed files with 3983 additions and 5063 deletions

View File

@@ -0,0 +1,70 @@
package mail
import (
"github.com/tech/sendico/notification/interface/api/localizer"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/messaging"
nn "github.com/tech/sendico/pkg/messaging/notifications/notification"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
type AmpliMailer struct {
logger mlogger.Logger
producer messaging.Producer
client Client
source string
}
func (am *AmpliMailer) Send(m mmail.MailBuilder) error {
err := am.client.Send(m)
if err != nil {
am.logger.Warn("Failed to send email", zap.Error(err))
}
opResult := model.OperationResult{
IsSuccessful: err == nil,
}
if !opResult.IsSuccessful {
opResult.Error = err.Error()
}
msg, e := m.Build()
if e != nil {
am.logger.Warn("Failed to build message content", zap.Error(e))
return e
}
if er := am.producer.SendMessage(nn.NotificationSent(am.source, &model.NotificationResult{
Channel: "email",
TemplateID: msg.TemplateID(),
Locale: msg.Locale(),
AmpliEvent: model.AmpliEvent{
UserID: "",
},
Result: opResult,
})); er != nil {
am.logger.Warn("Failed to send mailing result", zap.Error(er))
}
return err
}
func (am *AmpliMailer) MailBuilder() mmail.MailBuilder {
return am.client.MailBuilder()
}
func NewAmpliMailer(log mlogger.Logger, sender string, producer messaging.Producer, l localizer.Localizer, dp domainprovider.DomainProvider, config *Config) (*AmpliMailer, error) {
logger := log.Named("ampli")
c, err := createMailClient(logger, producer, l, dp, config)
if err != nil {
logger.Warn("Failed to create mailng driver", zap.Error(err), zap.String("driver", config.Driver))
return nil, err
}
am := &AmpliMailer{
logger: logger,
client: c,
producer: producer,
source: sender,
}
am.logger.Info("Amplitude wrapper installed")
return am, nil
}

View File

@@ -0,0 +1,54 @@
package mailimp
import (
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/merrors"
)
type MessageBuilderImp struct {
message *MessageImp
}
func (mb *MessageBuilderImp) SetAccountID(accountID string) mmail.MailBuilder {
mb.message.accountUID = accountID
return mb
}
func (mb *MessageBuilderImp) SetTemplateID(templateID string) mmail.MailBuilder {
mb.message.templateID = templateID
return mb
}
func (mb *MessageBuilderImp) SetLocale(locale string) mmail.MailBuilder {
mb.message.locale = locale
return mb
}
func (mb *MessageBuilderImp) AddButton(link string) mmail.MailBuilder {
mb.message.buttonLink = link
return mb
}
func (mb *MessageBuilderImp) AddRecipient(recipientName, recipient string) mmail.MailBuilder {
mb.message.recipientName = recipientName
mb.message.recipients = append(mb.message.recipients, recipient)
return mb
}
func (mb *MessageBuilderImp) AddData(key, value string) mmail.MailBuilder {
mb.message.parameters[key] = value
return mb
}
func (mb *MessageBuilderImp) Build() (mmail.Message, error) {
if len(mb.message.recipients) == 0 {
return nil, merrors.InvalidArgument("Recipient not set")
}
return mb.message, nil
}
func NewMessageBuilder() *MessageBuilderImp {
return &MessageBuilderImp{
message: createMessageImp(),
}
}

View File

@@ -0,0 +1,251 @@
package mailimp
import (
"errors"
"testing"
"github.com/tech/sendico/pkg/merrors"
)
func TestNewMessageBuilder_CreatesValidBuilder(t *testing.T) {
builder := NewMessageBuilder()
if builder == nil {
t.Fatal("Expected non-nil builder")
}
if builder.message == nil {
t.Fatal("Expected builder to have initialized message")
}
}
func TestMessageBuilder_BuildWithoutRecipient_ReturnsError(t *testing.T) {
builder := NewMessageBuilder()
_, err := builder.Build()
if err == nil {
t.Fatal("Expected error when building without recipient")
}
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Errorf("Expected InvalidArgument error, got: %v", err)
}
}
func TestMessageBuilder_BuildWithRecipient_Success(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
AddRecipient("John Doe", "john@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg == nil {
t.Fatal("Expected non-nil message")
}
}
func TestMessageBuilder_SetAccountID_SetsCorrectValue(t *testing.T) {
builder := NewMessageBuilder()
accountID := "507f1f77bcf86cd799439011"
msg, err := builder.
SetAccountID(accountID).
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg.AccountID() != accountID {
t.Errorf("Expected AccountID %s, got %s", accountID, msg.AccountID())
}
}
func TestMessageBuilder_SetTemplateID_SetsCorrectValue(t *testing.T) {
builder := NewMessageBuilder()
templateID := "welcome"
msg, err := builder.
SetTemplateID(templateID).
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg.TemplateID() != templateID {
t.Errorf("Expected TemplateID %s, got %s", templateID, msg.TemplateID())
}
}
func TestMessageBuilder_SetLocale_SetsCorrectValue(t *testing.T) {
builder := NewMessageBuilder()
locale := "en-US"
msg, err := builder.
SetLocale(locale).
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg.Locale() != locale {
t.Errorf("Expected Locale %s, got %s", locale, msg.Locale())
}
}
func TestMessageBuilder_AddRecipient_AddsToRecipientsList(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
AddRecipient("User One", "user1@example.com").
AddRecipient("User Two", "user2@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
recipients := msg.Recipients()
if len(recipients) != 2 {
t.Fatalf("Expected 2 recipients, got %d", len(recipients))
}
if recipients[0] != "user1@example.com" {
t.Errorf("Expected first recipient to be user1@example.com, got %s", recipients[0])
}
if recipients[1] != "user2@example.com" {
t.Errorf("Expected second recipient to be user2@example.com, got %s", recipients[1])
}
}
func TestMessageBuilder_AddData_AccumulatesParameters(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
AddData("key1", "value1").
AddData("key2", "value2").
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
params := msg.Parameters()
if len(params) != 2 {
t.Fatalf("Expected 2 parameters, got %d", len(params))
}
if params["key1"] != "value1" {
t.Errorf("Expected key1=value1, got %v", params["key1"])
}
if params["key2"] != "value2" {
t.Errorf("Expected key2=value2, got %v", params["key2"])
}
}
func TestMessageBuilder_AddButton_StoresButtonLink(t *testing.T) {
builder := NewMessageBuilder()
buttonLink := "https://example.com/verify"
msg, err := builder.
AddButton(buttonLink).
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Button link is internal, but we can verify the message was built successfully
if msg == nil {
t.Fatal("Expected non-nil message with button")
}
}
func TestMessageBuilder_ChainedMethods_SetsAllFields(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
SetAccountID("507f1f77bcf86cd799439011").
SetTemplateID("welcome").
SetLocale("en-US").
AddButton("https://example.com/verify").
AddRecipient("John Doe", "john@example.com").
AddData("name", "John").
AddData("age", "30").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg.AccountID() != "507f1f77bcf86cd799439011" {
t.Errorf("AccountID not set correctly")
}
if msg.TemplateID() != "welcome" {
t.Errorf("TemplateID not set correctly")
}
if msg.Locale() != "en-US" {
t.Errorf("Locale not set correctly")
}
if len(msg.Recipients()) != 1 {
t.Errorf("Recipients not set correctly")
}
if len(msg.Parameters()) != 2 {
t.Errorf("Parameters not set correctly")
}
}
func TestMessageBuilder_MultipleBuilds_IndependentMessages(t *testing.T) {
builder1 := NewMessageBuilder()
builder2 := NewMessageBuilder()
msg1, err1 := builder1.
SetTemplateID("template1").
AddRecipient("User 1", "user1@example.com").
Build()
msg2, err2 := builder2.
SetTemplateID("template2").
AddRecipient("User 2", "user2@example.com").
Build()
if err1 != nil || err2 != nil {
t.Fatalf("Unexpected errors: %v, %v", err1, err2)
}
if msg1.TemplateID() == msg2.TemplateID() {
t.Error("Messages should be independent with different template IDs")
}
if msg1.Recipients()[0] == msg2.Recipients()[0] {
t.Error("Messages should be independent with different recipients")
}
}
func TestMessageBuilder_EmptyValues_AreAllowed(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
SetAccountID("").
SetTemplateID("").
SetLocale("").
AddButton("").
AddRecipient("", "user@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Empty values should be allowed - business logic validation happens elsewhere
if msg == nil {
t.Fatal("Expected message to be built even with empty values")
}
}

View File

@@ -0,0 +1,150 @@
package mailimp
import (
"maps"
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/mailkey"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/localization"
)
type EmailNotificationTemplate struct {
dp domainprovider.DomainProvider
l localizer.Localizer
data localization.LocData
unsubscribable bool
hasButton bool
}
func (m *EmailNotificationTemplate) AddData(key, value string) {
localization.AddLocData(m.data, key, value)
}
// content:
// Greeting: Welcome, Gregory
// Content: You're receiving this message because you recently signed up for an account.<br><br>Confirm your email address by clicking the button below. This step adds extra security to your business by verifying you own this email.
// LogoLink: link to a logo
// Privacy: Privacy Policy
// PolicyLink: link to a privacy policy
// Unsubscribe: Unsubscribe
// UnsubscribeLink: link to an unsubscribe command
// MessageTitle: message title
func (m *EmailNotificationTemplate) prepareUnsubscribe(msg mmail.Message) error {
var block string
if m.unsubscribable {
var d localization.LocData
unsubscribe, err := m.l.LocalizeString("mail.template.unsubscribe", msg.Locale())
if err != nil {
return err
}
localization.AddLocData(d, "Unsubscribe", unsubscribe)
unsLink, err := m.dp.GetFullLink("account", "unsubscribe", msg.AccountID())
if err != nil {
return err
}
localization.AddLocData(d, "UnsubscribeLink", unsLink)
if block, err = m.l.LocalizeTemplate("mail.template.unsubscribe.block", d, nil, msg.Locale()); err != nil {
return err
}
}
m.AddData("UnsubscribeBlock", block)
return nil
}
func (m *EmailNotificationTemplate) prepareButton(msg mmail.Message) error {
var block string
if m.hasButton {
var err error
if block, err = m.l.LocalizeTemplate("mail.template.btn.block", m.data, nil, msg.Locale()); err != nil {
return err
}
}
m.AddData("ButtonBlock", block)
return nil
}
func (m *EmailNotificationTemplate) SignatureData(msg mmail.Message, content, subj string) (string, error) {
m.AddData("Content", content)
m.AddData("MessageTitle", subj)
logoLink, err := m.dp.GetAPILink("logo", msg.AccountID(), msg.TemplateID())
if err != nil {
return "", err
}
m.AddData("LogoLink", logoLink)
privacy, err := m.l.LocalizeString("mail.template.privacy", msg.Locale())
if err != nil {
return "", err
}
m.AddData("Privacy", privacy)
ppLink, err := m.dp.GetFullLink("/privacy-policy")
if err != nil {
return "", err
}
m.AddData("PolicyLink", ppLink)
if err := m.prepareButton(msg); err != nil {
return "", err
}
if err := m.prepareUnsubscribe(msg); err != nil {
return "", err
}
return m.l.LocalizeTemplate("mail.template.one_button", m.data, nil, msg.Locale())
}
func (m *EmailNotificationTemplate) putOnHTMLTemplate(msg mmail.Message, content, subj string) (string, error) {
greeting, err := m.l.LocalizeTemplate(mailkey.Get(msg.TemplateID(), "greeting"), m.data, nil, msg.Locale())
if err != nil {
return "", err
}
m.AddData("Greeting", greeting)
return m.SignatureData(msg, content, subj)
}
func (m *EmailNotificationTemplate) Build(msg mmail.Message) (string, error) {
if m.data != nil {
m.data["ServiceName"] = m.l.ServiceName()
m.data["SupportMail"] = m.l.SupportMail()
var err error
if m.data["ServiceOwner"], err = m.l.LocalizeString("service.owner", msg.Locale()); err != nil {
return "", err
}
if m.data["OwnerAddress"], err = m.l.LocalizeString("service.address", msg.Locale()); err != nil {
return "", err
}
if m.data["OwnerPhone"], err = m.l.LocalizeString("service.phone", msg.Locale()); err != nil {
return "", err
}
maps.Copy(m.data, msg.Parameters())
}
content, err := mailkey.Body(m.l, m.data, msg.TemplateID(), msg.Locale())
if err != nil {
return "", err
}
subject, err := mailkey.Subject(m.l, m.data, msg.TemplateID(), msg.Locale())
if err != nil {
return "", err
}
return m.putOnHTMLTemplate(msg, content, subject)
}
func (t *EmailNotificationTemplate) SetUnsubscribable(isUnsubscribable bool) {
t.unsubscribable = isUnsubscribable
}
func (t *EmailNotificationTemplate) SetButton(hasButton bool) {
t.hasButton = hasButton
}
func NewEmailNotification(l localizer.Localizer, dp domainprovider.DomainProvider) *EmailNotificationTemplate {
p := &EmailNotificationTemplate{
dp: dp,
l: l,
data: localization.LocData{},
}
p.unsubscribable = false
p.hasButton = false
return p
}

View File

@@ -0,0 +1,56 @@
package mailimp
import (
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/pkg/domainprovider"
)
type MessageImp struct {
templateID string
accountUID string
locale string
recipients []string
recipientName string
buttonLink string
parameters map[string]any
}
func (m *MessageImp) TemplateID() string {
return m.templateID
}
func (m *MessageImp) Locale() string {
return m.locale
}
func (m *MessageImp) AccountID() string {
return m.accountUID
}
func (m *MessageImp) Recipients() []string {
return m.recipients
}
func (m *MessageImp) Parameters() map[string]any {
return m.parameters
}
func (m *MessageImp) Body(l localizer.Localizer, dp domainprovider.DomainProvider) (string, error) {
if len(m.buttonLink) == 0 {
return NewEmailNotification(l, dp).Build(m)
}
page := NewOneButton(l, dp)
buttonLabel, err := l.LocalizeString("btn."+m.TemplateID(), m.Locale())
if err != nil {
return "", err
}
page.AddButton(buttonLabel, m.buttonLink)
return page.Build(m)
}
func createMessageImp() *MessageImp {
return &MessageImp{
parameters: map[string]any{},
recipients: []string{},
}
}

View File

@@ -0,0 +1,256 @@
package mailimp
import (
"fmt"
"testing"
)
// Mock implementations for testing
type mockLocalizer struct {
localizeTemplateFunc func(id string, templateData, ctr any, lang string) (string, error)
localizeStringFunc func(id, lang string) (string, error)
serviceName string
supportMail string
}
func (m *mockLocalizer) LocalizeTemplate(id string, templateData, ctr any, lang string) (string, error) {
if m.localizeTemplateFunc != nil {
return m.localizeTemplateFunc(id, templateData, ctr, lang)
}
// Return a simple HTML template for testing
return fmt.Sprintf("<html><body>Template: %s</body></html>", id), nil
}
func (m *mockLocalizer) LocalizeString(id, lang string) (string, error) {
if m.localizeStringFunc != nil {
return m.localizeStringFunc(id, lang)
}
return fmt.Sprintf("string:%s", id), nil
}
func (m *mockLocalizer) ServiceName() string {
if m.serviceName != "" {
return m.serviceName
}
return "TestService"
}
func (m *mockLocalizer) SupportMail() string {
if m.supportMail != "" {
return m.supportMail
}
return "support@test.com"
}
type mockDomainProvider struct {
getFullLinkFunc func(linkElem ...string) (string, error)
getAPILinkFunc func(linkElem ...string) (string, error)
}
func (m *mockDomainProvider) GetFullLink(linkElem ...string) (string, error) {
if m.getFullLinkFunc != nil {
return m.getFullLinkFunc(linkElem...)
}
return "https://example.com/link", nil
}
func (m *mockDomainProvider) GetAPILink(linkElem ...string) (string, error) {
if m.getAPILinkFunc != nil {
return m.getAPILinkFunc(linkElem...)
}
return "https://api.example.com/link", nil
}
// Tests
func TestMessageImp_TemplateID_ReturnsCorrectValue(t *testing.T) {
msg := createMessageImp()
msg.templateID = "welcome"
if msg.TemplateID() != "welcome" {
t.Errorf("Expected templateID 'welcome', got '%s'", msg.TemplateID())
}
}
func TestMessageImp_Locale_ReturnsCorrectValue(t *testing.T) {
msg := createMessageImp()
msg.locale = "en-US"
if msg.Locale() != "en-US" {
t.Errorf("Expected locale 'en-US', got '%s'", msg.Locale())
}
}
func TestMessageImp_AccountID_ReturnsCorrectValue(t *testing.T) {
msg := createMessageImp()
msg.accountUID = "507f1f77bcf86cd799439011"
if msg.AccountID() != "507f1f77bcf86cd799439011" {
t.Errorf("Expected accountUID '507f1f77bcf86cd799439011', got '%s'", msg.AccountID())
}
}
func TestMessageImp_Recipients_ReturnsCorrectList(t *testing.T) {
msg := createMessageImp()
msg.recipients = []string{"user1@example.com", "user2@example.com"}
recipients := msg.Recipients()
if len(recipients) != 2 {
t.Fatalf("Expected 2 recipients, got %d", len(recipients))
}
if recipients[0] != "user1@example.com" {
t.Errorf("Expected first recipient 'user1@example.com', got '%s'", recipients[0])
}
if recipients[1] != "user2@example.com" {
t.Errorf("Expected second recipient 'user2@example.com', got '%s'", recipients[1])
}
}
func TestMessageImp_Parameters_ReturnsCorrectMap(t *testing.T) {
msg := createMessageImp()
msg.parameters["key1"] = "value1"
msg.parameters["key2"] = "value2"
params := msg.Parameters()
if len(params) != 2 {
t.Fatalf("Expected 2 parameters, got %d", len(params))
}
if params["key1"] != "value1" {
t.Errorf("Expected key1='value1', got '%v'", params["key1"])
}
if params["key2"] != "value2" {
t.Errorf("Expected key2='value2', got '%v'", params["key2"])
}
}
func TestMessageImp_Body_WithButton_CallsOneButtonTemplate(t *testing.T) {
msg := createMessageImp()
msg.templateID = "welcome"
msg.locale = "en-US"
msg.buttonLink = "https://example.com/verify"
mockLoc := &mockLocalizer{
localizeStringFunc: func(id, lang string) (string, error) {
// Mock all localization calls that might occur
switch id {
case "btn.welcome":
return "Verify Account", nil
case "service.owner", "service.name":
return "Test Service", nil
default:
return fmt.Sprintf("localized:%s", id), nil
}
},
}
mockDP := &mockDomainProvider{}
body, err := msg.Body(mockLoc, mockDP)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if body == "" {
t.Error("Expected non-empty body")
}
// Body should be HTML from one-button template
// We can't test exact content without knowing template implementation,
// but we can verify it succeeded
}
func TestMessageImp_Body_WithoutButton_CallsEmailNotification(t *testing.T) {
msg := createMessageImp()
msg.templateID = "notification"
msg.locale = "en-US"
msg.buttonLink = "" // No button
mockLoc := &mockLocalizer{}
mockDP := &mockDomainProvider{}
body, err := msg.Body(mockLoc, mockDP)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if body == "" {
t.Error("Expected non-empty body")
}
}
func TestMessageImp_Body_LocalizationError_ReturnsError(t *testing.T) {
msg := createMessageImp()
msg.templateID = "welcome"
msg.locale = "invalid-locale"
msg.buttonLink = "https://example.com/verify"
mockLoc := &mockLocalizer{
localizeStringFunc: func(id, lang string) (string, error) {
return "", fmt.Errorf("localization failed for lang: %s", lang)
},
}
mockDP := &mockDomainProvider{}
_, err := msg.Body(mockLoc, mockDP)
if err == nil {
t.Error("Expected error from localization failure")
}
}
func TestCreateMessageImp_InitializesEmptyCollections(t *testing.T) {
msg := createMessageImp()
if msg.parameters == nil {
t.Error("Expected parameters map to be initialized")
}
if msg.recipients == nil {
t.Error("Expected recipients slice to be initialized")
}
if len(msg.parameters) != 0 {
t.Error("Expected parameters map to be empty")
}
if len(msg.recipients) != 0 {
t.Error("Expected recipients slice to be empty")
}
}
func TestMessageImp_MultipleParameterTypes_StoresCorrectly(t *testing.T) {
msg := createMessageImp()
msg.parameters["string"] = "value"
msg.parameters["number"] = 42
msg.parameters["bool"] = true
params := msg.Parameters()
if params["string"] != "value" {
t.Error("String parameter not stored correctly")
}
if params["number"] != 42 {
t.Error("Number parameter not stored correctly")
}
if params["bool"] != true {
t.Error("Boolean parameter not stored correctly")
}
}
func TestMessageImp_EmptyTemplateID_AllowedByGetter(t *testing.T) {
msg := createMessageImp()
msg.templateID = ""
// Should not panic or error
result := msg.TemplateID()
if result != "" {
t.Errorf("Expected empty string, got '%s'", result)
}
}
func TestMessageImp_EmptyLocale_AllowedByGetter(t *testing.T) {
msg := createMessageImp()
msg.locale = ""
// Should not panic or error
result := msg.Locale()
if result != "" {
t.Errorf("Expected empty string, got '%s'", result)
}
}

View File

@@ -0,0 +1,33 @@
package mailimp
import (
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/pkg/domainprovider"
)
type OneButtonTemplate struct {
EmailNotificationTemplate
}
func (b *OneButtonTemplate) AddButtonText(text string) {
b.AddData("ButtonText", text)
}
func (b *OneButtonTemplate) AddButtonLink(link string) {
b.AddData("ButtonLink", link)
}
func (b *OneButtonTemplate) AddButton(text, link string) {
b.AddButtonText(text)
b.AddButtonLink(link)
}
func NewOneButton(l localizer.Localizer, dp domainprovider.DomainProvider) *OneButtonTemplate {
p := &OneButtonTemplate{
EmailNotificationTemplate: *NewEmailNotification(l, dp),
}
p.SetUnsubscribable(false)
p.SetButton(true)
return p
}

View File

@@ -0,0 +1,29 @@
package mailimp
import (
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/builder"
b "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
)
type Dummy struct {
logger mlogger.Logger
}
func (d *Dummy) Send(_ b.MailBuilder) error {
d.logger.Warn("Unexpected request to send email")
return merrors.NotImplemented("MailDummy::Send")
}
func (d *Dummy) MailBuilder() b.MailBuilder {
return mb.NewMessageBuilder()
}
func NewDummy(logger mlogger.Logger) (*Dummy, error) {
d := &Dummy{
logger: logger.Named("dummy"),
}
d.logger.Info("Mailer installed")
return d, nil
}

View File

@@ -0,0 +1,98 @@
package mailimp
import (
"errors"
"testing"
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/builder"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger/factory"
)
func TestNewDummy_CreatesValidClient(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Unexpected error creating dummy client: %v", err)
}
if dummy == nil {
t.Fatal("Expected non-nil dummy client")
}
}
func TestDummy_Send_ReturnsNotImplementedError(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Failed to create dummy client: %v", err)
}
builder := mb.NewMessageBuilder()
err = dummy.Send(builder)
if err == nil {
t.Fatal("Expected error when calling Send on dummy client")
}
if !errors.Is(err, merrors.ErrNotImplemented) {
t.Errorf("Expected NotImplemented error, got: %v", err)
}
}
func TestDummy_MailBuilder_ReturnsValidBuilder(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Failed to create dummy client: %v", err)
}
builder := dummy.MailBuilder()
if builder == nil {
t.Fatal("Expected non-nil mail builder")
}
}
func TestDummy_MailBuilder_CanBuildMessage(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Failed to create dummy client: %v", err)
}
builder := dummy.MailBuilder()
msg, err := builder.
AddRecipient("Test User", "test@example.com").
SetTemplateID("welcome").
Build()
if err != nil {
t.Fatalf("Unexpected error building message: %v", err)
}
if msg == nil {
t.Fatal("Expected non-nil message")
}
}
func TestDummy_MultipleSendCalls_AllReturnError(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Failed to create dummy client: %v", err)
}
builder1 := dummy.MailBuilder()
builder2 := dummy.MailBuilder()
err1 := dummy.Send(builder1)
err2 := dummy.Send(builder2)
if err1 == nil || err2 == nil {
t.Error("Expected all Send calls to return errors")
}
if !errors.Is(err1, merrors.ErrNotImplemented) || !errors.Is(err2, merrors.ErrNotImplemented) {
t.Error("Expected all errors to be NotImplemented")
}
}

View File

@@ -0,0 +1,174 @@
package mailimp
import (
"crypto/tls"
"time"
"github.com/tech/sendico/notification/interface/api/localizer"
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/builder"
"github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/mailkey"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
mutil "github.com/tech/sendico/pkg/mutil/config"
mduration "github.com/tech/sendico/pkg/mutil/duration"
mail "github.com/xhit/go-simple-mail/v2"
"go.uber.org/zap"
)
// Client implements a mail client
type Client struct {
logger mlogger.Logger
server *mail.SMTPServer
client *mail.SMTPClient
from string
l localizer.Localizer
dp domainprovider.DomainProvider
}
// Config represents the mail configuration
type GSMConfig struct {
Username *string `mapstructure:"username,omitempty" yaml:"username,omitempty"`
UsernameEnv *string `mapstructure:"username_env,omitempty" yaml:"username_env,omitempty"`
Password *string `mapstructure:"password" yaml:"password"`
PasswordEnv *string `mapstructure:"password_env" yaml:"password_env"`
Host string `mapstructure:"host" yaml:"host"`
Port int `mapstructure:"port" yaml:"port"`
From string `mapstructure:"from" yaml:"from"`
TimeOut int `mapstructure:"network_timeout" yaml:"network_timeout"`
}
func (c *Client) sendImp(m mmail.Message, msg *mail.Email) error {
err := msg.Send(c.client)
if err != nil {
c.logger.Warn("Error sending email", zap.Error(err), zap.String("template_id", m.TemplateID()), zap.Strings("recipients", msg.GetRecipients()))
} else {
c.logger.Info("Email sent", zap.Strings("recipients", msg.GetRecipients()), zap.String("template_id", m.TemplateID()))
}
// TODO: add amplitude notification
return err
}
// Send sends an email message to the provided address and with the provided subject
func (c *Client) Send(r mmail.MailBuilder) error {
// New email simple html with inline and CC
r.AddData("ServiceName", c.l.ServiceName()).AddData("SupportMail", c.l.SupportMail())
m, err := r.Build()
if err != nil {
c.logger.Warn("Failed to build message", zap.Error(err))
return err
}
body, err := m.Body(c.l, c.dp)
if err != nil {
c.logger.Warn("Failed to build message body", zap.Error(err))
return err
}
if (len(body) == 0) || (len(m.Recipients()) == 0) {
c.logger.Warn("Malformed messge", zap.String("template_id", m.TemplateID()),
zap.String("locale", m.Locale()), zap.Strings("recipients", m.Recipients()),
zap.Int("body_size", len(body)))
return merrors.InvalidArgument("malformed message")
}
subj, err := mailkey.Subject(c.l, m.Parameters(), m.TemplateID(), m.Locale())
if err != nil {
c.logger.Warn("Failed to localize subject", zap.Error(err), zap.String("template_id", m.TemplateID()),
zap.String("locale", m.Locale()), zap.Strings("recipients", m.Recipients()),
zap.Int("body_size", len(body)))
return err
}
msg := mail.NewMSG()
msg.SetFrom(c.from).
AddTo(m.Recipients()...).
SetSubject(subj).
SetBody(mail.TextHTML, body)
// Call Send and pass the client
if err = c.sendImp(m, msg); err != nil {
c.logger.Info("Failed to send an email, attempting to reconnect...",
zap.Error(err),
zap.String("template", m.TemplateID()), zap.Strings("addresses", m.Recipients()), zap.String("locale", m.Locale()))
c.client = nil
c.client, err = c.server.Connect()
if err != nil {
c.logger.Warn("Failed to reconnect mail client",
zap.Error(err),
zap.String("template", m.TemplateID()), zap.Strings("addresses", m.Recipients()), zap.String("locale", m.Locale()))
return err
}
c.logger.Info("Connection has been successfully restored",
zap.Error(err),
zap.String("template", m.TemplateID()), zap.Strings("addresses", m.Recipients()), zap.String("locale", m.Locale()))
err = c.sendImp(m, msg)
if err != nil {
c.logger.Warn("Failed to send message after mail client recreation",
zap.Error(err),
zap.String("template", m.TemplateID()), zap.Strings("addresses", m.Recipients()), zap.String("locale", m.Locale()))
return err
}
}
return err
}
func (c *Client) MailBuilder() mmail.MailBuilder {
return mb.NewMessageBuilder()
}
// NewClient return a new mail
func NewClient(logger mlogger.Logger, l localizer.Localizer, dp domainprovider.DomainProvider, config *GSMConfig) *Client {
smtpServer := mail.NewSMTPClient()
// SMTP Server
smtpServer.Host = config.Host
if config.Port < 1 {
logger.Warn("Invalid mail client port configuration, defaulting to 465", zap.Int("port", config.Port))
config.Port = 465
}
smtpServer.Port = config.Port
smtpServer.Username = mutil.GetConfigValue(logger, "username", "username_env", config.Username, config.UsernameEnv)
smtpServer.Password = mutil.GetConfigValue(logger, "password", "password_env", config.Password, config.PasswordEnv)
smtpServer.Encryption = mail.EncryptionSSL
// Since v2.3.0 you can specified authentication type:
// - PLAIN (default)
// - LOGIN
// - CRAM-MD5
// server.Authentication = mail.AuthPlain
// Variable to keep alive connection
smtpServer.KeepAlive = true
// Timeout for connect to SMTP Server
smtpServer.ConnectTimeout = mduration.Param2Duration(config.TimeOut, time.Second)
// Timeout for send the data and wait respond
smtpServer.SendTimeout = mduration.Param2Duration(config.TimeOut, time.Second)
// Set TLSConfig to provide custom TLS configuration. For example,
// to skip TLS verification (useful for testing):
smtpServer.TLSConfig = &tls.Config{InsecureSkipVerify: true}
// SMTP client
lg := logger.Named("client")
smtpClient, err := smtpServer.Connect()
if err != nil {
lg.Warn("Failed to connect", zap.Error(err))
} else {
lg.Info("Connected successfully", zap.String("username", smtpServer.Username), zap.String("host", config.Host))
}
from := config.From + " <" + smtpServer.Username + ">"
return &Client{
logger: lg,
server: smtpServer,
client: smtpClient,
from: from,
l: l,
dp: dp,
}
}

View File

@@ -0,0 +1,15 @@
package mailkey
import "github.com/tech/sendico/notification/interface/api/localizer"
func Get(template, part string) string {
return "mail." + template + "." + part
}
func Subject(l localizer.Localizer, data map[string]any, templateID, locale string) (string, error) {
return l.LocalizeTemplate(Get(templateID, "subj"), data, nil, locale)
}
func Body(l localizer.Localizer, data map[string]any, templateID, locale string) (string, error) {
return l.LocalizeTemplate(Get(templateID, "body"), data, nil, locale)
}

View File

@@ -0,0 +1,104 @@
package mailimp
import (
"net/http"
"os"
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/builder"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
"go.uber.org/zap"
)
type KeysConfig struct {
Email string `yaml:"email"`
Name string `yaml:"name"`
URL string `yaml:"url"`
ID string `yaml:"id"`
}
type Sender struct {
Address string `yaml:"address"`
Name string `yaml:"name"`
}
type SGEmailConfig struct {
Sender Sender `yaml:"sender"`
}
type SendGridConfig struct {
APIKeyEnv string `yaml:"api_key_env"`
Email SGEmailConfig `yaml:"email"`
Keys KeysConfig `yaml:"keys"`
}
type SendGridNotifier struct {
logger mlogger.Logger
client *sendgrid.Client
config *SendGridConfig
producer messaging.Producer
}
func (sg *SendGridNotifier) Send(mb mmail.MailBuilder) error {
m := mail.NewV3Mail()
e := mail.NewEmail(sg.config.Email.Sender.Name, sg.config.Email.Sender.Address)
m.SetFrom(e)
task, err := mb.Build()
if err != nil {
sg.logger.Warn("Failed to build message", zap.Error(err))
return err
}
m.SetTemplateID(task.TemplateID())
p := mail.NewPersonalization()
for _, recipient := range task.Recipients() {
p.AddTos(mail.NewEmail(recipient, recipient))
}
for k, v := range task.Parameters() {
p.SetDynamicTemplateData(k, v)
}
m.AddPersonalizations(p)
response, err := sg.client.Send(m)
if err != nil {
sg.logger.Warn("Failed to send email", zap.Error(err), zap.Any("task", &task))
return err
}
if (response.StatusCode != http.StatusOK) && (response.StatusCode != http.StatusAccepted) {
sg.logger.Warn("Unexpected SendGrid sresponse", zap.Int("status_code", response.StatusCode),
zap.String("sresponse", response.Body), zap.Any("task", &task))
return merrors.Internal("email_notification_not_sent")
}
sg.logger.Info("Email sent successfully", zap.Strings("recipients", task.Recipients()), zap.String("template_id", task.TemplateID()))
// if err = sg.producer.SendMessage(model.NewNotification(model.NTEmail, model.NAComplete), &task); err != nil {
// sg.logger.Warn("Failed to send email statistics", zap.Error(err), zap.Strings("recipients", task.Recipients), zap.String("template_id", task.TemplateID))
// }
return nil
}
func (sg *SendGridNotifier) MailBuilder() mmail.MailBuilder {
return mb.NewMessageBuilder()
}
func NewSendGridNotifier(logger mlogger.Logger, producer messaging.Producer, config *SendGridConfig) (*SendGridNotifier, error) {
apiKey := os.Getenv(config.APIKeyEnv)
if apiKey == "" {
logger.Warn("No SendGrid API key")
return nil, merrors.NoData("No SendGrid API key")
}
return &SendGridNotifier{
logger: logger.Named("sendgrid"),
client: sendgrid.NewSendClient(apiKey),
config: config,
producer: producer,
}, nil
}

View File

@@ -0,0 +1,53 @@
package mail
import (
"github.com/tech/sendico/notification/interface/api/localizer"
notification "github.com/tech/sendico/notification/interface/services/notification/config"
mi "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal"
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/mitchellh/mapstructure"
"go.uber.org/zap"
)
type Client interface {
Send(r mb.MailBuilder) error
MailBuilder() mb.MailBuilder
}
type Config = notification.Config
func createMailClient(logger mlogger.Logger, producer messaging.Producer, l localizer.Localizer, dp domainprovider.DomainProvider, config *Config) (Client, error) {
if len(config.Driver) == 0 {
return nil, merrors.InvalidArgument("Mail driver name must be provided")
}
logger.Info("Connecting mail client...", zap.String("driver", config.Driver))
if config.Driver == "dummy" {
return mi.NewDummy(logger)
}
if config.Driver == "sendgrid" {
var sgconfig mi.SendGridConfig
if err := mapstructure.Decode(config.Settings, &sgconfig); err != nil {
logger.Error("Failed to decode driver settings", zap.Error(err), zap.String("driver", config.Driver))
return nil, err
}
return mi.NewSendGridNotifier(logger, producer, &sgconfig)
}
if config.Driver == "client" {
var gsmconfing mi.GSMConfig
if err := mapstructure.Decode(config.Settings, &gsmconfing); err != nil {
logger.Error("Failed to decode driver settings", zap.Error(err), zap.String("driver", config.Driver))
return nil, err
}
return mi.NewClient(logger, l, dp, &gsmconfing), nil
}
return nil, merrors.InvalidArgument("Unkwnown mail driver: " + config.Driver)
}
func CreateMailClient(logger mlogger.Logger, sender string, producer messaging.Producer, l localizer.Localizer, dp domainprovider.DomainProvider, config *Config) (Client, error) {
return NewAmpliMailer(logger, sender, producer, l, dp, config)
}

View File

@@ -0,0 +1,11 @@
package mmail
type MailBuilder interface {
SetAccountID(accountID string) MailBuilder
SetTemplateID(templateID string) MailBuilder
SetLocale(locale string) MailBuilder
AddRecipient(recipientName, recipient string) MailBuilder
AddButton(link string) MailBuilder
AddData(key, value string) MailBuilder
Build() (Message, error)
}

View File

@@ -0,0 +1,20 @@
package mmail
import (
"time"
mgt "github.com/tech/sendico/pkg/mutil/time/go"
)
func AddDate(b MailBuilder, t time.Time) {
b.AddData("Date", mgt.ToDate(t))
}
func AddTime(b MailBuilder, t time.Time) {
b.AddData("Time", mgt.ToTime(t))
}
func AddDateAndTime(b MailBuilder, t time.Time) {
AddDate(b, t)
AddTime(b, t)
}

View File

@@ -0,0 +1,15 @@
package mmail
import (
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/pkg/domainprovider"
)
type Message interface {
AccountID() string
TemplateID() string
Locale() string
Recipients() []string
Parameters() map[string]any
Body(l localizer.Localizer, dp domainprovider.DomainProvider) (string, error)
}