fx build fix
This commit is contained in:
734
api/notification/internal/ampli/ampli.go
Normal file
734
api/notification/internal/ampli/ampli.go
Normal 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 = &litude.Plan{
|
||||
Branch: `main`,
|
||||
Source: `backend`,
|
||||
Version: `2`,
|
||||
VersionID: `4fa6851a-4ff0-42f1-b440-8b39f07870e4`,
|
||||
}
|
||||
}
|
||||
|
||||
if clientConfig.IngestionMetadata == nil {
|
||||
clientConfig.IngestionMetadata = &litude.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...)
|
||||
}
|
||||
146
api/notification/internal/api/api.go
Normal file
146
api/notification/internal/api/api.go
Normal 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
|
||||
}
|
||||
42
api/notification/internal/api/config/config.go
Executable file
42
api/notification/internal/api/config/config.go
Executable 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
|
||||
59
api/notification/internal/api/middleware.go
Normal file
59
api/notification/internal/api/middleware.go
Normal 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
|
||||
}
|
||||
27
api/notification/internal/appversion/version.go
Executable file
27
api/notification/internal/appversion/version.go
Executable 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)
|
||||
}
|
||||
151
api/notification/internal/localizer/loc_imp.go
Normal file
151
api/notification/internal/localizer/loc_imp.go
Normal 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
|
||||
}
|
||||
45
api/notification/internal/server/amplitude/amplitude.go
Executable file
45
api/notification/internal/server/amplitude/amplitude.go
Executable 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
|
||||
}
|
||||
16
api/notification/internal/server/amplitude/nsent.go
Normal file
16
api/notification/internal/server/amplitude/nsent.go
Normal 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
|
||||
}
|
||||
128
api/notification/internal/server/internal/serverimp.go
Normal file
128
api/notification/internal/server/internal/serverimp.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
174
api/notification/internal/server/notificationimp/mail/internal/mailimp.go
Executable file
174
api/notification/internal/server/notificationimp/mail/internal/mailimp.go
Executable 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
11
api/notification/internal/server/server.go
Normal file
11
api/notification/internal/server/server.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user