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,734 @@
// ampli.go
//
// Ampli - A strong typed wrapper for your Analytics
//
// This file is generated by Amplitude.
// To update run 'ampli pull backend'
//
// Required dependencies: github.com/amplitude/analytics-go@latest
// Tracking Plan Version: 2
// Build: 1.0.0
// Runtime: go-ampli
//
// View Tracking Plan: https://data.eu.amplitude.com/profee/Profee%20Tips/events/main/latest
//
// Full Setup Instructions: https://data.eu.amplitude.com/profee/Profee%20Tips/implementation/main/latest/getting-started/backend
//
package ampli
import (
"log"
"sync"
"github.com/amplitude/analytics-go/amplitude"
)
type (
EventOptions = amplitude.EventOptions
ExecuteResult = amplitude.ExecuteResult
)
const (
IdentifyEventType = amplitude.IdentifyEventType
GroupIdentifyEventType = amplitude.GroupIdentifyEventType
ServerZoneUS = amplitude.ServerZoneUS
ServerZoneEU = amplitude.ServerZoneEU
)
var (
NewClientConfig = amplitude.NewConfig
NewClient = amplitude.NewClient
)
var Instance = Ampli{}
type Environment string
const (
EnvironmentProfeetips Environment = `profeetips`
)
var APIKey = map[Environment]string{
EnvironmentProfeetips: `c4e543cf70e8c83b85eb56e9a1d9b4b3`,
}
// LoadClientOptions is Client options setting to initialize Ampli client.
//
// Params:
// - APIKey: the API key of Amplitude project
// - Instance: the core SDK instance used by Ampli client
// - Configuration: the core SDK client configuration instance
type LoadClientOptions struct {
APIKey string
Instance amplitude.Client
Configuration amplitude.Config
}
// LoadOptions is options setting to initialize Ampli client.
//
// Params:
// - Environment: the environment of Amplitude Data project
// - Disabled: the flag of disabled Ampli client
// - Client: the LoadClientOptions struct
type LoadOptions struct {
Environment Environment
Disabled bool
Client LoadClientOptions
}
type baseEvent struct {
eventType string
properties map[string]any
}
type Event interface {
ToAmplitudeEvent() amplitude.Event
}
func newBaseEvent(eventType string, properties map[string]any) baseEvent {
return baseEvent{
eventType: eventType,
properties: properties,
}
}
func (event baseEvent) ToAmplitudeEvent() amplitude.Event {
return amplitude.Event{
EventType: event.eventType,
EventProperties: event.properties,
}
}
var EmailOpened = struct {
Builder func() interface {
EmailType(emailType string) EmailOpenedBuilder
}
}{
Builder: func() interface {
EmailType(emailType string) EmailOpenedBuilder
} {
return &emailOpenedBuilder{
properties: map[string]any{},
}
},
}
type EmailOpenedEvent interface {
Event
emailOpened()
}
type emailOpenedEvent struct {
baseEvent
}
func (e emailOpenedEvent) emailOpened() {
}
type EmailOpenedBuilder interface {
Build() EmailOpenedEvent
}
type emailOpenedBuilder struct {
properties map[string]any
}
func (b *emailOpenedBuilder) EmailType(emailType string) EmailOpenedBuilder {
b.properties[`emailType`] = emailType
return b
}
func (b *emailOpenedBuilder) Build() EmailOpenedEvent {
return &emailOpenedEvent{
newBaseEvent(`emailOpened`, b.properties),
}
}
var EmailSent = struct {
Builder func() interface {
Domain(domain string) interface {
EmailType(emailType string) EmailSentBuilder
}
}
}{
Builder: func() interface {
Domain(domain string) interface {
EmailType(emailType string) EmailSentBuilder
}
} {
return &emailSentBuilder{
properties: map[string]any{},
}
},
}
type EmailSentEvent interface {
Event
emailSent()
}
type emailSentEvent struct {
baseEvent
}
func (e emailSentEvent) emailSent() {
}
type EmailSentBuilder interface {
Build() EmailSentEvent
}
type emailSentBuilder struct {
properties map[string]any
}
func (b *emailSentBuilder) Domain(domain string) interface {
EmailType(emailType string) EmailSentBuilder
} {
b.properties[`domain`] = domain
return b
}
func (b *emailSentBuilder) EmailType(emailType string) EmailSentBuilder {
b.properties[`emailType`] = emailType
return b
}
func (b *emailSentBuilder) Build() EmailSentEvent {
return &emailSentEvent{
newBaseEvent(`emailSent`, b.properties),
}
}
var PaymentFailed = struct {
Builder func() interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
}
}
}
}
}{
Builder: func() interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
}
}
}
} {
return &paymentFailedBuilder{
properties: map[string]any{},
}
},
}
type PaymentFailedEvent interface {
Event
paymentFailed()
}
type paymentFailedEvent struct {
baseEvent
}
func (e paymentFailedEvent) paymentFailed() {
}
type PaymentFailedBuilder interface {
Build() PaymentFailedEvent
Comment(comment string) PaymentFailedBuilder
Source(source string) PaymentFailedBuilder
}
type paymentFailedBuilder struct {
properties map[string]any
}
func (b *paymentFailedBuilder) Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
}
}
} {
b.properties[`amount`] = amount
return b
}
func (b *paymentFailedBuilder) Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
}
} {
b.properties[`domain`] = domain
return b
}
func (b *paymentFailedBuilder) Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
} {
b.properties[`fee`] = fee
return b
}
func (b *paymentFailedBuilder) FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
} {
b.properties[`feeCoveredBy`] = feeCoveredBy
return b
}
func (b *paymentFailedBuilder) Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
} {
b.properties[`product`] = product
return b
}
func (b *paymentFailedBuilder) ProductQty(productQty int) PaymentFailedBuilder {
b.properties[`product_qty`] = productQty
return b
}
func (b *paymentFailedBuilder) Comment(comment string) PaymentFailedBuilder {
b.properties[`comment`] = comment
return b
}
func (b *paymentFailedBuilder) Source(source string) PaymentFailedBuilder {
b.properties[`source`] = source
return b
}
func (b *paymentFailedBuilder) Build() PaymentFailedEvent {
return &paymentFailedEvent{
newBaseEvent(`paymentFailed`, b.properties),
}
}
var PaymentSuccess = struct {
Builder func() interface {
Price(price float64) interface {
ProductId(productId string) interface {
Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
}
}
}
}
}{
Builder: func() interface {
Price(price float64) interface {
ProductId(productId string) interface {
Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
}
}
}
} {
return &paymentSuccessBuilder{
properties: map[string]any{},
}
},
}
type PaymentSuccessEvent interface {
Event
paymentSuccess()
}
type paymentSuccessEvent struct {
baseEvent
}
func (e paymentSuccessEvent) paymentSuccess() {
}
type PaymentSuccessBuilder interface {
Build() PaymentSuccessEvent
Quantity(quantity int) PaymentSuccessBuilder
Comment(comment string) PaymentSuccessBuilder
}
type paymentSuccessBuilder struct {
properties map[string]any
}
func (b *paymentSuccessBuilder) Price(price float64) interface {
ProductId(productId string) interface {
Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
}
}
} {
b.properties[`$price`] = price
return b
}
func (b *paymentSuccessBuilder) ProductId(productId string) interface {
Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
}
} {
b.properties[`$productId`] = productId
return b
}
func (b *paymentSuccessBuilder) Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
} {
b.properties[`$revenue`] = revenue
return b
}
func (b *paymentSuccessBuilder) RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
} {
b.properties[`$revenueType`] = revenueType
return b
}
func (b *paymentSuccessBuilder) Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
} {
b.properties[`amount`] = amount
return b
}
func (b *paymentSuccessBuilder) Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
} {
b.properties[`domain`] = domain
return b
}
func (b *paymentSuccessBuilder) Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
} {
b.properties[`fee`] = fee
return b
}
func (b *paymentSuccessBuilder) FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
} {
b.properties[`feeCoveredBy`] = feeCoveredBy
return b
}
func (b *paymentSuccessBuilder) Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
} {
b.properties[`product`] = product
return b
}
func (b *paymentSuccessBuilder) ProductQty(productQty int) PaymentSuccessBuilder {
b.properties[`product_qty`] = productQty
return b
}
func (b *paymentSuccessBuilder) Quantity(quantity int) PaymentSuccessBuilder {
b.properties[`$quantity`] = quantity
return b
}
func (b *paymentSuccessBuilder) Comment(comment string) PaymentSuccessBuilder {
b.properties[`comment`] = comment
return b
}
func (b *paymentSuccessBuilder) Build() PaymentSuccessEvent {
return &paymentSuccessEvent{
newBaseEvent(`paymentSuccess`, b.properties),
}
}
type Ampli struct {
Disabled bool
Client amplitude.Client
mutex sync.RWMutex
}
// Load initializes the Ampli wrapper.
// Call once when your application starts.
func (a *Ampli) Load(options LoadOptions) {
if a.Client != nil {
log.Print("Warn: Ampli is already initialized. Ampli.Load() should be called once at application start up.")
return
}
var apiKey string
switch {
case options.Client.APIKey != "":
apiKey = options.Client.APIKey
case options.Environment != "":
apiKey = APIKey[options.Environment]
default:
apiKey = options.Client.Configuration.APIKey
}
if apiKey == "" && options.Client.Instance == nil {
log.Print("Error: Ampli.Load() requires option.Environment, " +
"and apiKey from either options.Instance.APIKey or APIKey[options.Environment], " +
"or options.Instance.Instance")
}
clientConfig := options.Client.Configuration
if clientConfig.Plan == nil {
clientConfig.Plan = &amplitude.Plan{
Branch: `main`,
Source: `backend`,
Version: `2`,
VersionID: `4fa6851a-4ff0-42f1-b440-8b39f07870e4`,
}
}
if clientConfig.IngestionMetadata == nil {
clientConfig.IngestionMetadata = &amplitude.IngestionMetadata{
SourceName: `go-go-ampli`,
SourceVersion: `2.0.0`,
}
}
if clientConfig.ServerZone == "" {
clientConfig.ServerZone = ServerZoneEU
}
if options.Client.Instance != nil {
a.Client = options.Client.Instance
} else {
clientConfig.APIKey = apiKey
a.Client = amplitude.NewClient(clientConfig)
}
a.mutex.Lock()
a.Disabled = options.Disabled
a.mutex.Unlock()
}
// InitializedAndEnabled checks if Ampli is initialized and enabled.
func (a *Ampli) InitializedAndEnabled() bool {
if a.Client == nil {
log.Print("Error: Ampli is not yet initialized. Have you called Ampli.Load() on app start?")
return false
}
a.mutex.RLock()
defer a.mutex.RUnlock()
return !a.Disabled
}
func (a *Ampli) setUserID(userID string, eventOptions *EventOptions) {
if userID != "" {
eventOptions.UserID = userID
}
}
// Track tracks an event.
func (a *Ampli) Track(userID string, event Event, eventOptions ...EventOptions) {
if !a.InitializedAndEnabled() {
return
}
var options EventOptions
if len(eventOptions) > 0 {
options = eventOptions[0]
}
a.setUserID(userID, &options)
baseEvent := event.ToAmplitudeEvent()
baseEvent.EventOptions = options
a.Client.Track(baseEvent)
}
// Identify identifies a user and set user properties.
func (a *Ampli) Identify(userID string, eventOptions ...EventOptions) {
identify := newBaseEvent(IdentifyEventType, nil)
a.Track(userID, identify, eventOptions...)
}
// Flush flushes events waiting in buffer.
func (a *Ampli) Flush() {
if !a.InitializedAndEnabled() {
return
}
a.Client.Flush()
}
// Shutdown disables and shutdowns Ampli Instance.
func (a *Ampli) Shutdown() {
if !a.InitializedAndEnabled() {
return
}
a.mutex.Lock()
a.Disabled = true
a.mutex.Unlock()
a.Client.Shutdown()
}
func (a *Ampli) EmailOpened(userID string, event EmailOpenedEvent, eventOptions ...EventOptions) {
a.Track(userID, event, eventOptions...)
}
func (a *Ampli) EmailSent(userID string, event EmailSentEvent, eventOptions ...EventOptions) {
a.Track(userID, event, eventOptions...)
}
func (a *Ampli) PaymentFailed(userID string, event PaymentFailedEvent, eventOptions ...EventOptions) {
a.Track(userID, event, eventOptions...)
}
func (a *Ampli) PaymentSuccess(userID string, event PaymentSuccessEvent, eventOptions ...EventOptions) {
a.Track(userID, event, eventOptions...)
}

View File

@@ -0,0 +1,146 @@
package apiimp
import (
"context"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/notification/interface/api"
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/notification/interface/services/amplitude"
"github.com/tech/sendico/notification/interface/services/notification"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
type Microservices = []mservice.MicroService
type APIImp struct {
logger mlogger.Logger
db db.Factory
localizer localizer.Localizer
domain domainprovider.DomainProvider
config *api.Config
services Microservices
debug bool
mw *Middleware
}
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
a.services = append(a.services, srv)
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
}
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
srv, err := srvf(a)
if err != nil {
a.logger.Error("Failed to install a microservice", zap.Error(err))
return err
}
a.installMicroservice(srv)
return nil
}
func (a *APIImp) Logger() mlogger.Logger {
return a.logger
}
func (a *APIImp) Config() *api.Config {
return a.config
}
func (a *APIImp) Localizer() localizer.Localizer {
return a.localizer
}
func (a *APIImp) DBFactory() db.Factory {
return a.db
}
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
return a.domain
}
func (a *APIImp) Register() messaging.Register {
return a.mw
}
func (a *APIImp) installServices() error {
srvf := make([]api.MicroServiceFactoryT, 0)
srvf = append(srvf, amplitude.Create)
srvf = append(srvf, notification.Create)
for _, v := range srvf {
err := a.addMicroservice(v)
if err != nil {
return err
}
}
a.mw.SetStatus("ok")
return nil
}
func (a *APIImp) Finish(ctx context.Context) error {
a.mw.SetStatus("deactivating")
a.mw.Finish()
var lastError error
// stop services in the reverse order
for i := len(a.services) - 1; i >= 0; i-- {
err := (a.services[i]).Finish(ctx)
if err != nil {
lastError = err
a.logger.Warn("Error occurred when finishing service",
zap.Error(err),
zap.String("service_name", (a.services[i]).Name()))
} else {
a.logger.Info("Microservice is down",
zap.String("service_name", (a.services[i]).Name()))
}
}
return lastError
}
func (a *APIImp) Name() string {
return "api"
}
func CreateAPI(logger mlogger.Logger, config *api.Config, l localizer.Localizer, db db.Factory, router *chi.Mux, debug bool) (mservice.MicroService, error) {
p := new(APIImp)
p.logger = logger.Named("api")
p.debug = debug
p.config = config
p.db = db
p.localizer = l
var err error
if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil {
p.logger.Error("Failed to initizlize domain provider")
return nil, err
}
odb, err := db.NewOrganizationDB()
if err != nil {
p.logger.Warn("Failed to create organization database", zap.Error(err))
return nil, err
}
if p.mw, err = CreateMiddleware(logger, odb, router, config.Mw, debug); err != nil {
p.logger.Warn("Failed to create middleware", zap.Error(err))
return nil, err
}
p.logger.Info("Installing microservices...")
if err := p.installServices(); err != nil {
p.logger.Error("Failed to install a microservice", zap.Error(err))
return nil, err
}
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
return p, nil
}

View File

@@ -0,0 +1,42 @@
package apiimp
import "github.com/tech/sendico/pkg/messaging"
type CORSSettings struct {
MaxAge int `yaml:"max_age"`
AllowedOrigins []string `yaml:"allowed_origins"`
AllowedMethods []string `yaml:"allowed_methods"`
AllowedHeaders []string `yaml:"allowed_headers"`
ExposedHeaders []string `yaml:"exposed_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
}
type SignatureConf struct {
PublicKey any
PrivateKey []byte
Algorithm string
}
type Signature struct {
PublicKeyEnv string `yaml:"public_key_env,omitempty"`
PrivateKeyEnv string `yaml:"secret_key_env"`
Algorithm string `yaml:"algorithm"`
}
type WebSocketConfig struct {
EndpointEnv string `yaml:"endpoint_env"`
Timeout int `yaml:"timeout"`
}
type MessagingConfig struct {
BufferSize int `yaml:"buffer_size"`
}
type Config struct {
DomainEnv string `yaml:"domain_env"`
EndPointEnv string `yaml:"api_endpoint_env"`
APIProtocolEnv string `yaml:"api_protocol_env"`
Messaging messaging.Config `yaml:"message_broker"`
}
type MapClaims = map[string]any

View File

@@ -0,0 +1,59 @@
package apiimp
import (
"os"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/notification/interface/middleware"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/db/organization"
"github.com/tech/sendico/pkg/messaging"
notifications "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Middleware struct {
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
health routers.Health
messaging routers.Messaging
}
func (mw *Middleware) Consumer(processor notifications.EnvelopeProcessor) error {
return mw.messaging.Consumer(processor)
}
func (mw *Middleware) Producer() messaging.Producer {
return mw.messaging.Producer()
}
func (mw *Middleware) Finish() {
mw.messaging.Finish()
mw.health.Finish()
}
func (mw *Middleware) SetStatus(status health.ServiceStatus) {
mw.health.SetStatus(status)
}
func CreateMiddleware(logger mlogger.Logger, db organization.DB, router *chi.Mux, config *middleware.Config, debug bool) (*Middleware, error) {
p := &Middleware{
logger: logger.Named("middleware"),
router: router,
apiEndpoint: os.Getenv(config.EndPointEnv),
}
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
var err error
if p.messaging, err = routers.NewMessagingRouter(logger, &config.Messaging); err != nil {
p.logger.Error("Failed to create messaging router", zap.Error(err))
return nil, err
}
if p.health, err = routers.NewHealthRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
return p, nil
}

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "MeetX Connectica Notification Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}

View File

@@ -0,0 +1,151 @@
package lclrimp
import (
"encoding/json"
"path"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/fr"
"github.com/nicksnyder/go-i18n/v2/i18n"
"go.uber.org/zap"
"golang.org/x/text/language"
)
type Lang struct {
bundle *i18n.Bundle
localizer *i18n.Localizer
}
type Localizers = map[string]Lang
type Localizer struct {
logger mlogger.Logger
l9rs Localizers
support string
serviceName string
}
type Config struct {
Path string `yaml:"path"`
Langs []string `yaml:"languages"`
Support string `yaml:"support"`
ServiceName string `yaml:"service_name"`
}
func loadBundleLocalization(logger mlogger.Logger, bundle *i18n.Bundle, localizationPath string) error {
b, err := fr.ReadFile(logger, localizationPath)
if err != nil {
logger.Error("Failed to read localization", zap.Error(err), zap.String("localization_path", localizationPath))
return err
}
_, err = bundle.ParseMessageFileBytes(b, localizationPath)
if err != nil {
logger.Error("Failed to parse localization", zap.Error(err), zap.String("localization_path", localizationPath))
return err
}
return err
}
func loadLocalizations(logger mlogger.Logger, source string) (*i18n.Bundle, error) {
bundle := i18n.NewBundle(language.English)
// Register a json unmarshal function for i18n bundle.
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// Load translations from json files for non-default languages.
err := loadBundleLocalization(logger, bundle, source)
if err != nil {
// will not log error once again, just return nil
return nil, err
}
return bundle, nil
}
func newLang(logger mlogger.Logger, language string, source string) (*Lang, error) {
var lang Lang
var err error
lang.bundle, err = loadLocalizations(logger, source)
if err != nil {
logger.Error("Failed to install language bundle", zap.Error(err),
zap.String("language", language), zap.String("source", source))
return nil, err
}
lang.localizer = i18n.NewLocalizer(lang.bundle, language)
if lang.localizer != nil {
logger.Info("Installed language bundle",
zap.String("language", language), zap.String("source", source))
} else {
logger.Error("Failed to install language bundle", zap.String("language", language), zap.String("source", source))
return nil, merrors.Internal("failed_to_load_localization")
}
return &lang, nil
}
func prepareLocalizers(logger mlogger.Logger, conf *Config) (Localizers, error) {
localizers := make(Localizers)
for _, lang := range conf.Langs {
path := path.Join(conf.Path, lang+".json")
l, err := newLang(logger, lang, path)
if err != nil {
logger.Error("Failed to load localization", zap.Error(err), zap.String("language", lang), zap.String("source", path))
return localizers, err
}
localizers[lang] = *l
}
return localizers, nil
}
func (loc *Localizer) LocalizeTemplate(id string, templateData, ctr any, lang string) (string, error) {
lclzr, found := loc.l9rs[lang]
if !found {
loc.logger.Info("Language not found, falling back to en", zap.String("message_id", id), zap.String("language", lang))
lclzr = loc.l9rs["en"]
}
config := i18n.LocalizeConfig{
MessageID: id,
TemplateData: templateData,
PluralCount: ctr,
}
localized, err := lclzr.localizer.Localize(&config)
if err != nil {
loc.logger.Warn("Failed to localize string", zap.Error(err), zap.String("message_id", id), zap.String("language", lang))
}
return localized, err
}
func (loc *Localizer) LocalizeString(id string, lang string) (string, error) {
return loc.LocalizeTemplate(id, nil, nil, lang)
}
func (loc *Localizer) ServiceName() string {
return loc.serviceName
}
func (loc *Localizer) SupportMail() string {
return loc.support
}
// NewConnection creates a new database connection
func CreateLocalizer(logger mlogger.Logger, config *Config) (*Localizer, error) {
p := new(Localizer)
p.logger = logger.Named("localizer")
var err error
p.l9rs, err = prepareLocalizers(p.logger, config)
if err != nil {
logger.Warn("Failed to create localizer", zap.Error(err))
return nil, err
}
p.serviceName = config.ServiceName
p.support = config.Support
logger.Info("Localizer is up", zap.String("service_name", p.serviceName), zap.String("support", p.support))
return p, nil
}

View File

@@ -0,0 +1,45 @@
package ampliimp
import (
"context"
"os"
"github.com/amplitude/analytics-go/amplitude"
"github.com/tech/sendico/notification/interface/api"
"github.com/tech/sendico/notification/internal/ampli"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
type AmplitudeAPI struct {
logger mlogger.Logger
}
func (a *AmplitudeAPI) Name() mservice.Type {
return "amplitude"
}
func (a *AmplitudeAPI) Finish(_ context.Context) error {
ampli.Instance.Flush()
return nil
}
func CreateAPI(a api.API) (*AmplitudeAPI, error) {
p := new(AmplitudeAPI)
p.logger = a.Logger().Named(p.Name())
env := os.Getenv(a.Config().Amplitude.Environment)
ampli.Instance.Load(ampli.LoadOptions{
Environment: ampli.EnvironmentProfeetips,
Client: ampli.LoadClientOptions{
Configuration: amplitude.Config{
Logger: p.logger.Named("ampli").Sugar(),
ServerZone: ampli.ServerZoneEU,
},
},
})
p.logger.Info("Amplitude environment is set", zap.String("ampli_environment", env))
return p, nil
}

View File

@@ -0,0 +1,16 @@
package ampliimp
import (
"context"
"github.com/tech/sendico/notification/internal/ampli"
"github.com/tech/sendico/pkg/model"
)
func (a *AmplitudeAPI) onNotificationSent(_ context.Context, nresult *model.NotificationResult) error {
ampli.Instance.EmailSent(
nresult.UserID,
ampli.EmailSent.Builder().Domain("").EmailType("").Build(),
)
return nil
}

View File

@@ -0,0 +1,128 @@
package serverimp
import (
"context"
"errors"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/notification/interface/api"
"github.com/tech/sendico/notification/interface/api/localizer"
apiimip "github.com/tech/sendico/notification/internal/api"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
mduration "github.com/tech/sendico/pkg/mutil/duration"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type httpServerConf struct {
ListenAddress string `yaml:"listen_address"`
ReadHeaderTimeout int `yaml:"read_header_timeout"`
ShutdownTimeout int `yaml:"shutdown_timeout"`
}
// Config represents the server configuration
type Config struct {
API *api.Config `yaml:"api"`
DB *db.Config `yaml:"database"`
Localizer *localizer.Config `yaml:"localizer"`
HTTPServer *httpServerConf `yaml:"http_server"`
}
// Instance represents an instance of the server
type Imp struct {
logger mlogger.Logger
api mservice.MicroService
config *Config
db db.Factory
l localizer.Localizer
httpServer *http.Server
debug bool
file string
}
// Shutdown stops the server
func (i *Imp) Shutdown() {
// Shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), mduration.Param2Duration(i.config.HTTPServer.ShutdownTimeout, time.Second))
i.logger.Info("Shutting HTTP server down...")
if err := i.httpServer.Shutdown(ctx); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
i.logger.Warn("Failed to shutdown HTTP server gracefully", zap.Error(err))
cancel()
os.Exit(1)
}
}
cancel()
}
func (i *Imp) Run() error {
if err := i.httpServer.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
i.logger.Error("HTTP Server stopped unexpectedly", zap.Error(err))
}
}
i.logger.Info("HTTP Server stopped")
if err := i.api.Finish(context.Background()); err != nil {
i.logger.Warn("Error when finishing service", zap.Error(err))
}
i.db.CloseConnection()
return nil
}
// Start starts the server
func (i *Imp) Start() error {
i.logger.Info("Starting...", zap.String("config_file", i.file), zap.Bool("debug_mode", i.debug))
// Load configuration file
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not load configuration", zap.Error(err), zap.String("config_file", i.file))
return err
}
if err = yaml.Unmarshal(data, &i.config); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return err
}
if i.db, err = db.NewConnection(i.logger, i.config.DB); err != nil {
i.logger.Error("Could not open database connection", zap.Error(err))
return err
}
if i.l, err = localizer.CreateLocalizer(i.logger, i.config.Localizer); err != nil {
i.logger.Error("Failed to create localizer", zap.Error(err))
return err
}
router := chi.NewRouter()
if i.api, err = apiimip.CreateAPI(i.logger, i.config.API, i.l, i.db, router, i.debug); err != nil {
i.logger.Error("Failed to create API instance", zap.Error(err))
return err
}
// Startup the HTTP Server in a way that we can gracefully shut it down again
i.httpServer = &http.Server{
Addr: i.config.HTTPServer.ListenAddress,
Handler: router,
ReadHeaderTimeout: mduration.Param2Duration(i.config.HTTPServer.ReadHeaderTimeout, time.Second),
}
return i.Run()
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
srv := &Imp{
logger: logger,
debug: debug,
file: file,
}
return srv, nil
}

View File

@@ -0,0 +1,29 @@
package notificationimp
import (
"context"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
func (a *NotificationAPI) onAccount(context context.Context, account *model.Account) error {
var link string
var err error
if link, err = a.dp.GetFullLink("verify", account.VerifyToken); err != nil {
a.logger.Warn("Failed to generate verification link", zap.Error(err), zap.String("login", account.Login))
return err
}
mr := a.client.MailBuilder().
AddRecipient(account.Name, account.Login).
SetAccountID(account.ID.Hex()).
SetLocale(account.Locale).
AddButton(link).
SetTemplateID("welcome")
if err := a.client.Send(mr); err != nil {
a.logger.Warn("Failed to send verification email", zap.Error(err), zap.String("login", account.Login))
return err
}
a.logger.Info("Verification email sent", zap.String("login", account.Login))
return nil
}

View File

@@ -0,0 +1,32 @@
package notificationimp
import (
"context"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
func (a *NotificationAPI) onInvitation(context context.Context, account *model.Account, invitation *model.Invitation) error {
var link string
var err error
if link, err = a.dp.GetFullLink(mservice.Invitations, invitation.ID.Hex()); err != nil {
a.logger.Warn("Failed to generate invitation link", zap.Error(err), zap.String("email", invitation.Content.Email))
return err
}
mr := a.client.MailBuilder().
AddData("InviterName", account.Name).
AddData("Name", invitation.Content.Name).
AddRecipient(invitation.Content.Name, invitation.Content.Email).
SetAccountID(account.ID.Hex()).
SetLocale(account.Locale).
AddButton(link).
SetTemplateID("invitation")
if err := a.client.Send(mr); err != nil {
a.logger.Warn("Failed to send verification email", zap.Error(err), zap.String("email", invitation.Content.Email))
return err
}
a.logger.Info("Invitation email sent", zap.String("to", invitation.Content.Email), zap.String("on_behalf_of", account.Name))
return nil
}

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)
}

View File

@@ -0,0 +1,68 @@
package notificationimp
import (
"context"
"github.com/tech/sendico/notification/interface/api"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail"
"github.com/tech/sendico/pkg/domainprovider"
na "github.com/tech/sendico/pkg/messaging/notifications/account"
ni "github.com/tech/sendico/pkg/messaging/notifications/invitation"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
type NotificationAPI struct {
logger mlogger.Logger
client mmail.Client
dp domainprovider.DomainProvider
}
func (a *NotificationAPI) Name() mservice.Type {
return mservice.Notifications
}
func (a *NotificationAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a api.API) (*NotificationAPI, error) {
p := &NotificationAPI{
dp: a.DomainProvider(),
}
p.logger = a.Logger().Named(p.Name())
var err error
if p.client, err = mmail.CreateMailClient(p.logger.Named("mailer"), p.Name(), a.Register().Producer(), a.Localizer(), a.DomainProvider(), a.Config().Notification); err != nil {
p.logger.Error("Failed to create mail connection", zap.Error(err), zap.String("driver", a.Config().Notification.Driver))
return nil, err
}
db, err := a.DBFactory().NewAccountDB()
if err != nil {
p.logger.Error("Failed to create account db connection", zap.Error(err))
return nil, err
}
if err := a.Register().Consumer(na.NewAccountCreatedMessageProcessor(p.logger, db, p.onAccount)); err != nil {
p.logger.Error("Failed to create account creation handler", zap.Error(err))
return nil, err
}
if err := a.Register().Consumer(na.NewPasswordResetRequestedMessageProcessor(p.logger, db, p.onPasswordReset)); err != nil {
p.logger.Error("Failed to create password reset handler", zap.Error(err))
return nil, err
}
idb, err := a.DBFactory().NewInvitationsDB()
if err != nil {
p.logger.Error("Failed to create invitation db connection", zap.Error(err))
return nil, err
}
if err := a.Register().Consumer(ni.NewInvitationCreatedProcessor(p.logger, p.onInvitation, idb, db)); err != nil {
p.logger.Error("Failed to create invitation creation handler", zap.Error(err))
return nil, err
}
return p, nil
}

View File

@@ -0,0 +1,541 @@
package notificationimp
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/notification/interface/api/localizer"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mlogger/factory"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// Mock implementations
type mockMailClient struct {
sendFunc func(r mmail.MailBuilder) error
mailBuilderFunc func() mmail.MailBuilder
sentMessages []mockSentMessage
}
type mockSentMessage struct {
accountID string
templateID string
locale string
recipients []string
data map[string]string
buttonLink string
}
func (m *mockMailClient) Send(r mmail.MailBuilder) error {
if m.sendFunc != nil {
return m.sendFunc(r)
}
// Record the message for verification
msg, _ := r.Build()
if msg != nil {
sent := mockSentMessage{
accountID: msg.AccountID(),
templateID: msg.TemplateID(),
locale: msg.Locale(),
recipients: msg.Recipients(),
data: make(map[string]string),
}
// Extract string parameters
for k, v := range msg.Parameters() {
if str, ok := v.(string); ok {
sent.data[k] = str
}
}
m.sentMessages = append(m.sentMessages, sent)
}
return nil
}
func (m *mockMailClient) MailBuilder() mmail.MailBuilder {
if m.mailBuilderFunc != nil {
return m.mailBuilderFunc()
}
return &mockMailBuilder{
accountID: "",
templateID: "",
locale: "",
recipients: []string{},
data: make(map[string]string),
}
}
type mockMailBuilder struct {
accountID string
templateID string
locale string
recipients []string
buttonLink string
data map[string]string
}
func (m *mockMailBuilder) SetAccountID(accountID string) mmail.MailBuilder {
m.accountID = accountID
return m
}
func (m *mockMailBuilder) SetTemplateID(templateID string) mmail.MailBuilder {
m.templateID = templateID
return m
}
func (m *mockMailBuilder) SetLocale(locale string) mmail.MailBuilder {
m.locale = locale
return m
}
func (m *mockMailBuilder) AddRecipient(recipientName, recipient string) mmail.MailBuilder {
m.recipients = append(m.recipients, recipient)
return m
}
func (m *mockMailBuilder) AddButton(link string) mmail.MailBuilder {
m.buttonLink = link
return m
}
func (m *mockMailBuilder) AddData(key, value string) mmail.MailBuilder {
m.data[key] = value
return m
}
func (m *mockMailBuilder) Build() (mmail.Message, error) {
if len(m.recipients) == 0 {
return nil, errors.New("recipient not set")
}
return &mockMessage{
accountID: m.accountID,
templateID: m.templateID,
locale: m.locale,
recipients: m.recipients,
parameters: convertToAnyMap(m.data),
}, nil
}
type mockMessage struct {
accountID string
templateID string
locale string
recipients []string
parameters map[string]any
}
func (m *mockMessage) AccountID() string { return m.accountID }
func (m *mockMessage) TemplateID() string { return m.templateID }
func (m *mockMessage) Locale() string { return m.locale }
func (m *mockMessage) Recipients() []string { return m.recipients }
func (m *mockMessage) Parameters() map[string]any { return m.parameters }
func (m *mockMessage) Body(l localizer.Localizer, dp domainprovider.DomainProvider) (string, error) {
return "", nil
}
func convertToAnyMap(m map[string]string) map[string]any {
result := make(map[string]any)
for k, v := range m {
result[k] = v
}
return result
}
type mockDomainProvider struct {
getFullLinkFunc 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) {
return "https://api.example.com/link", nil
}
// Tests for onAccount handler
func TestOnAccount_ValidAccount_SendsWelcomeEmail(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
VerifyToken: "test-verify-token",
}
err := api.onAccount(context.Background(), account)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockClient.sentMessages) != 1 {
t.Fatalf("Expected 1 message sent, got %d", len(mockClient.sentMessages))
}
sent := mockClient.sentMessages[0]
if sent.templateID != "welcome" {
t.Errorf("Expected template 'welcome', got '%s'", sent.templateID)
}
if sent.locale != "en-US" {
t.Errorf("Expected locale 'en-US', got '%s'", sent.locale)
}
if len(sent.recipients) != 1 || sent.recipients[0] != "user@example.com" {
t.Errorf("Expected recipient 'user@example.com', got %v", sent.recipients)
}
}
func TestOnAccount_LinkGenerationFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{
getFullLinkFunc: func(linkElem ...string) (string, error) {
return "", errors.New("link generation failed")
},
}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
VerifyToken: "test-verify-token",
}
err := api.onAccount(context.Background(), account)
if err == nil {
t.Fatal("Expected error from link generation failure")
}
if len(mockClient.sentMessages) != 0 {
t.Error("No message should be sent when link generation fails")
}
}
func TestOnAccount_SendFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{
sendFunc: func(r mmail.MailBuilder) error {
return errors.New("send failed")
},
}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
VerifyToken: "test-verify-token",
}
err := api.onAccount(context.Background(), account)
if err == nil {
t.Fatal("Expected error from send failure")
}
}
// Tests for onInvitation handler
func TestOnInvitation_ValidInvitation_SendsInvitationEmail(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Inviter User",
},
},
UserDataBase: model.UserDataBase{
Locale: "en-US",
},
},
}
invitationID := primitive.NewObjectID()
invitation := &model.Invitation{}
invitation.ID = invitationID
invitation.Content.Email = "invitee@example.com"
invitation.Content.Name = "Invitee Name"
err := api.onInvitation(context.Background(), account, invitation)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockClient.sentMessages) != 1 {
t.Fatalf("Expected 1 message sent, got %d", len(mockClient.sentMessages))
}
sent := mockClient.sentMessages[0]
if sent.templateID != "invitation" {
t.Errorf("Expected template 'invitation', got '%s'", sent.templateID)
}
if sent.locale != "en-US" {
t.Errorf("Expected locale 'en-US', got '%s'", sent.locale)
}
if len(sent.recipients) != 1 || sent.recipients[0] != "invitee@example.com" {
t.Errorf("Expected recipient 'invitee@example.com', got %v", sent.recipients)
}
if sent.data["InviterName"] != "Inviter User" {
t.Errorf("Expected InviterName 'Inviter User', got '%s'", sent.data["InviterName"])
}
if sent.data["Name"] != "Invitee Name" {
t.Errorf("Expected Name 'Invitee Name', got '%s'", sent.data["Name"])
}
}
func TestOnInvitation_LinkGenerationFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{
getFullLinkFunc: func(linkElem ...string) (string, error) {
return "", errors.New("link generation failed")
},
}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Inviter User",
},
},
UserDataBase: model.UserDataBase{
Locale: "en-US",
},
},
}
invitationID := primitive.NewObjectID()
invitation := &model.Invitation{}
invitation.ID = invitationID
invitation.Content.Email = "invitee@example.com"
invitation.Content.Name = "Invitee Name"
err := api.onInvitation(context.Background(), account, invitation)
if err == nil {
t.Fatal("Expected error from link generation failure")
}
if len(mockClient.sentMessages) != 0 {
t.Error("No message should be sent when link generation fails")
}
}
// Tests for onPasswordReset handler
func TestOnPasswordReset_ValidReset_SendsResetEmail(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
}
resetToken := "reset-token-123"
err := api.onPasswordReset(context.Background(), account, resetToken)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockClient.sentMessages) != 1 {
t.Fatalf("Expected 1 message sent, got %d", len(mockClient.sentMessages))
}
sent := mockClient.sentMessages[0]
if sent.templateID != "reset-password" {
t.Errorf("Expected template 'reset-password', got '%s'", sent.templateID)
}
if sent.locale != "en-US" {
t.Errorf("Expected locale 'en-US', got '%s'", sent.locale)
}
if len(sent.recipients) != 1 || sent.recipients[0] != "user@example.com" {
t.Errorf("Expected recipient 'user@example.com', got %v", sent.recipients)
}
if sent.data["URL"] == "" {
t.Error("Expected URL parameter to be set")
}
}
func TestOnPasswordReset_LinkGenerationFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{
getFullLinkFunc: func(linkElem ...string) (string, error) {
return "", errors.New("link generation failed")
},
}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
}
err := api.onPasswordReset(context.Background(), account, "reset-token")
if err == nil {
t.Fatal("Expected error from link generation failure")
}
if len(mockClient.sentMessages) != 0 {
t.Error("No message should be sent when link generation fails")
}
}
func TestOnPasswordReset_SendFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{
sendFunc: func(r mmail.MailBuilder) error {
return errors.New("send failed")
},
}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
}
err := api.onPasswordReset(context.Background(), account, "reset-token")
if err == nil {
t.Fatal("Expected error from send failure")
}
}

View File

@@ -0,0 +1,30 @@
package notificationimp
import (
"context"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
func (a *NotificationAPI) onPasswordReset(context context.Context, account *model.Account, resetToken string) error {
var link string
var err error
if link, err = a.dp.GetFullLink("password", "reset", account.ID.Hex(), resetToken); err != nil {
a.logger.Warn("Failed to generate password reset link", zap.Error(err), zap.String("login", account.Login))
return err
}
mr := a.client.MailBuilder().
AddRecipient(account.Name, account.Login).
SetAccountID(account.ID.Hex()).
SetLocale(account.Locale).
AddButton(link).
AddData("URL", link).
SetTemplateID("reset-password")
if err := a.client.Send(mr); err != nil {
a.logger.Warn("Failed to send password reset email", zap.Error(err), zap.String("login", account.Login))
return err
}
a.logger.Info("Password reset email sent", zap.String("login", account.Login))
return nil
}

View File

@@ -0,0 +1,11 @@
package server
import (
serverimp "github.com/tech/sendico/notification/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}