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

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

BIN
api/notification/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,57 @@
# Config file for [Air](https://github.com/air-verse/air) in TOML format
# Working directory
# . or absolute path, please note that the directories following must be under root.
root = "./.."
tmp_dir = "tmp"
[build]
# Just plain old shell command. You could use `make` as well.
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/notification/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/notification/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/notification/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/notification/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/notification/internal/appversion.BuildDate=$(date)' -X 'github.com/tech/sendico/notification/internal/mutil/ampli.Version=$APP_V'\""
# Binary file yields from `cmd`.
bin = "./app"
# Customize binary, can setup environment variables when run your app.
full_bin = "./app --debug"
# Watch these filename extensions.
include_ext = ["go", "tpl", "tmpl", "html"]
# Ignore these filename extensions or directories.
exclude_dir = ["notification/.git", "pkg/.git", "notification/tmp", "notification/resources", "notification/env"]
# Watch these directories if you specified.
include_dir = []
# Watch these files.
include_file = []
# Exclude files.
exclude_file = []
# Exclude specific regular expressions.
exclude_regex = ["_test\\.go"]
# Exclude unchanged files.
exclude_unchanged = true
# Follow symlink for directories
follow_symlink = true
# This log file places in your tmp_dir.
log = "air.log"
# It's not necessary to trigger build each time file changes if it's too frequent.
delay = 0 # ms
# Stop running old binary when build errors occur.
stop_on_error = true
# Send Interrupt signal before killing process (windows does not support this feature)
send_interrupt = true
# Delay after sending Interrupt signal
kill_delay = 500 # ms
# Add additional arguments when running binary (bin/full_bin). Will run './tmp/main hello world'.
args_bin = []
[log]
# Show log time
time = false
[color]
# Customize each part's color. If no color found, use the raw app log.
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
# Delete tmp directory on exit
clean_on_exit = true

1
api/notification/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
go.sum

View File

@@ -0,0 +1,14 @@
{
"Zone": "eu",
"OrgId": "100001828",
"WorkspaceId": "c75043a3-1fad-45ec-bd71-c807a99c650d",
"SourceId": "81b24ac7-e285-4519-9e82-bb575601120c",
"Branch": "main",
"Version": "2.0.0",
"VersionId": "4fa6851a-4ff0-42f1-b440-8b39f07870e4",
"Runtime": "go:go-ampli",
"Platform": "Go",
"Language": "Go",
"SDK": "analytics-go",
"Path": "./internal/ampli"
}

85
api/notification/config.yml Executable file
View File

@@ -0,0 +1,85 @@
http_server:
listen_address: :8081
read_header_timeout: 60
shutdown_timeout: 5
api:
amplitude:
ampli_environment_env: AMPLI_ENVIRONMENT
middleware:
api_protocol_env: API_PROTOCOL
domain_env: SERVICE_HOST
api_endpoint_env: API_ENDPOINT
signature:
secret_key_env: API_ENDPOINT_SECRET
algorithm: HS256
CORS:
max_age: 300
allowed_origins:
- "http://*"
- "https://*"
allowed_methods:
- "GET"
- "POST"
- "PATCH"
- "DELETE"
- "OPTIONS"
allowed_headers:
- "Accept"
- "Authorization"
- "Content-Type"
exposed_headers:
allow_credentials: false
websocket:
endpoint_env: WS_ENDPOINT
timeout: 60
message_broker:
driver: NATS
settings:
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: Sendico Notification server
max_reconnects: 10
reconnect_wait: 5
# type: in-process
# settings:
# buffer_size: 10
notification:
driver: client
settings:
username_env: MAIL_USER
password_env: MAIL_SECRET
host: "smtp.mail.ru"
port: 465
from: "MeetX Tech"
network_timeout: 10
localizer:
path: "./i18n"
languages: ["en", "ru", "uk"]
service_name: "MeetX Connectica"
support: "support@meetx.space"
app:
database:
driver: mongodb
settings:
host_env: MONGO_HOST
port_env: MONGO_PORT
database_env: MONGO_DATABASE
user_env: MONGO_USER
password_env: MONGO_PASSWORD
auth_source_env: MONGO_AUTH_SOURCE
replica_set_env: MONGO_REPLICA_SET
enforcer:
driver: native
settings:
model_path_env: PERMISSION_MODEL
adapter:
collection_name_env: PERMISSION_COLLECTION
database_name_env: MONGO_DATABASE
timeout_seconds_env: PERMISSION_TIMEOUT
is_filtered_env: PERMISSION_IS_FILTERED

1
api/notification/env/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env.api

58
api/notification/go.mod Normal file
View File

@@ -0,0 +1,58 @@
module github.com/tech/sendico/notification
go 1.25.3
replace github.com/tech/sendico/pkg => ../pkg
require (
github.com/amplitude/analytics-go v1.2.0
github.com/go-chi/chi/v5 v5.2.3
github.com/mitchellh/mapstructure v1.5.0
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/sendgrid/sendgrid-go v3.16.1+incompatible
github.com/tech/sendico/pkg v0.1.0
github.com/xhit/go-simple-mail/v2 v2.16.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.0
golang.org/x/text v0.30.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.132.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
package api
import (
"github.com/tech/sendico/notification/interface/api/localizer"
"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"
)
type API interface {
Config() *Config
DBFactory() db.Factory
Logger() mlogger.Logger
Register() messaging.Register
Localizer() localizer.Localizer
DomainProvider() domainprovider.DomainProvider
}
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)

View File

@@ -0,0 +1,13 @@
package api
import (
mwa "github.com/tech/sendico/notification/interface/middleware"
amp "github.com/tech/sendico/notification/interface/services/amplitude/config"
not "github.com/tech/sendico/notification/interface/services/notification/config"
)
type Config struct {
Mw *mwa.Config `yaml:"middleware"`
Notification *not.Config `yaml:"notification"`
Amplitude *amp.Config `yaml:"amplitude"`
}

View File

@@ -0,0 +1,19 @@
package localizer
import (
lclrimp "github.com/tech/sendico/notification/internal/localizer"
"github.com/tech/sendico/pkg/mlogger"
)
type Config = *lclrimp.Config
type Localizer interface {
LocalizeTemplate(id string, templateData, ctr any, lang string) (string, error)
LocalizeString(id, lang string) (string, error)
ServiceName() string
SupportMail() string
}
func CreateLocalizer(logger mlogger.Logger, config *Config) (Localizer, error) {
return lclrimp.CreateLocalizer(logger, *config)
}

View File

@@ -0,0 +1,12 @@
package middleware
import (
ai "github.com/tech/sendico/notification/internal/api/config"
)
type (
Config = ai.Config
Signature = ai.SignatureConf
)
type MapClaims = ai.MapClaims

View File

@@ -0,0 +1,11 @@
package amplitude
import (
"github.com/tech/sendico/notification/interface/api"
ampliimp "github.com/tech/sendico/notification/internal/server/amplitude"
"github.com/tech/sendico/pkg/mservice"
)
func Create(a api.API) (mservice.MicroService, error) {
return ampliimp.CreateAPI(a)
}

View File

@@ -0,0 +1,5 @@
package amplitude
type Config struct {
Environment string `yaml:"ampli_environment_env"`
}

View File

@@ -0,0 +1,6 @@
package notificationimp
type Config struct {
Driver string `yaml:"driver"`
Settings map[string]any `yaml:"settings,omitempty"`
}

View File

@@ -0,0 +1,11 @@
package notification
import (
"github.com/tech/sendico/notification/interface/api"
"github.com/tech/sendico/notification/internal/server/notificationimp"
"github.com/tech/sendico/pkg/mservice"
)
func Create(a api.API) (mservice.MicroService, error) {
return notificationimp.CreateAPI(a)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

33
api/notification/main.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"github.com/tech/sendico/notification/internal/appversion"
si "github.com/tech/sendico/notification/internal/server"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
smain "github.com/tech/sendico/pkg/server/main"
)
// generate translations
// go:generate Users/stephandeshevikh/go/bin/go18n extract
// go:generate Users/stephandeshevikh/go/bin/go18n merge
// lint go code
// docker run -t --rm -v $(pwd):/app -w /app golangci/golangci-lint:latest golangci-lint run -v --timeout 10m0s --enable-all -D ireturn -D wrapcheck -D varnamelen -D tagliatelle -D nosnakecase -D gochecknoglobals -D nlreturn -D stylecheck -D lll -D wsl -D scopelint -D varcheck -D exhaustivestruct -D golint -D maligned -D interfacer -D ifshort -D structcheck -D deadcode -D godot -D depguard -D tagalign
// gofumpt source files
//go:generate /Users/stephandeshevikh/go/bin/gofumpt -w .
// gci source files
//go:generate /Users/stephandeshevikh/go/bin/gci write .
// get new ampli events
//go:generate ampli pull backend --path ./internal/ampli
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return si.Create(logger, file, debug)
}
func main() {
smain.RunServer("notification", appversion.Create(), factory)
}

BIN
api/notification/notification Executable file

Binary file not shown.