diff --git a/conf/example.yaml b/conf/example.yaml index 1ee4a2eeea..fbda035d8d 100644 --- a/conf/example.yaml +++ b/conf/example.yaml @@ -170,3 +170,40 @@ alertmanager: analytics: # Whether to enable analytics. enabled: false + +##################### Emailing ##################### +emailing: + # Whether to enable emailing. + enabled: false + templates: + # The directory containing the email templates. This directory should contain a list of files defined at pkg/types/emailtypes/template.go. + directory: /opt/signoz/conf/templates/email + smtp: + # The SMTP server address. + address: localhost:25 + # The email address to use for the SMTP server. + from: + # The hello message to use for the SMTP server. + hello: + # The static headers to send with the email. + headers: {} + auth: + # The username to use for the SMTP server. + username: + # The password to use for the SMTP server. + password: + # The secret to use for the SMTP server. + secret: + # The identity to use for the SMTP server. + identity: + tls: + # Whether to enable TLS. It should be false in most cases since the authentication mechanism should use the STARTTLS extension instead. + enabled: false + # Whether to skip TLS verification. + insecure_skip_verify: false + # The path to the CA file. + ca_file_path: + # The path to the key file. + key_file_path: + # The path to the certificate file. + cert_file_path: diff --git a/ee/modules/user/impluser/module.go b/ee/modules/user/impluser/module.go index 07d9c0a9e5..92200f2aa3 100644 --- a/ee/modules/user/impluser/module.go +++ b/ee/modules/user/impluser/module.go @@ -7,7 +7,9 @@ import ( "strings" "github.com/SigNoz/signoz/ee/query-service/constants" + "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/modules/user" baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/types" @@ -22,8 +24,8 @@ type Module struct { store types.UserStore } -func NewModule(store types.UserStore) user.Module { - baseModule := baseimpl.NewModule(store) +func NewModule(store types.UserStore, jwt *authtypes.JWT, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module { + baseModule := baseimpl.NewModule(store, jwt, emailing, providerSettings) return &Module{ Module: baseModule, store: store, diff --git a/ee/query-service/dao/sqlite/domain.go b/ee/query-service/dao/sqlite/domain.go index d41f7632ea..7acd051777 100644 --- a/ee/query-service/dao/sqlite/domain.go +++ b/ee/query-service/dao/sqlite/domain.go @@ -12,7 +12,6 @@ import ( "github.com/SigNoz/signoz/ee/query-service/model" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/types" - ossTypes "github.com/SigNoz/signoz/pkg/types" "github.com/google/uuid" "go.uber.org/zap" ) @@ -164,7 +163,7 @@ func (m *modelDao) CreateDomain(ctx context.Context, domain *types.GettableOrgDo Name: domain.Name, OrgID: domain.OrgID, Data: string(configJson), - TimeAuditable: ossTypes.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()}, + TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()}, } _, err = m.sqlStore.BunDB().NewInsert(). @@ -198,7 +197,7 @@ func (m *modelDao) UpdateDomain(ctx context.Context, domain *types.GettableOrgDo Name: domain.Name, OrgID: domain.OrgID, Data: string(configJson), - TimeAuditable: ossTypes.TimeAuditable{UpdatedAt: time.Now()}, + TimeAuditable: types.TimeAuditable{UpdatedAt: time.Now()}, } _, err = m.sqlStore.BunDB().NewUpdate(). diff --git a/ee/query-service/dao/sqlite/modelDao.go b/ee/query-service/dao/sqlite/modelDao.go index 6e55649e99..fd934aec2f 100644 --- a/ee/query-service/dao/sqlite/modelDao.go +++ b/ee/query-service/dao/sqlite/modelDao.go @@ -1,18 +1,14 @@ package sqlite import ( - "github.com/SigNoz/signoz/pkg/modules/user" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/sqlstore" ) type modelDao struct { - userModule user.Module - sqlStore sqlstore.SQLStore + sqlStore sqlstore.SQLStore } // InitDB creates and extends base model DB repository func NewModelDao(sqlStore sqlstore.SQLStore) *modelDao { - userModule := impluser.NewModule(impluser.NewStore(sqlStore)) - return &modelDao{userModule: userModule, sqlStore: sqlStore} + return &modelDao{sqlStore: sqlStore} } diff --git a/ee/query-service/main.go b/ee/query-service/main.go index 22b307dc12..bb8e0d7e17 100644 --- a/ee/query-service/main.go +++ b/ee/query-service/main.go @@ -14,6 +14,8 @@ import ( "github.com/SigNoz/signoz/pkg/config" "github.com/SigNoz/signoz/pkg/config/envprovider" "github.com/SigNoz/signoz/pkg/config/fileprovider" + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/modules/user" baseconst "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/signoz" @@ -112,26 +114,6 @@ func main() { zap.L().Fatal("Failed to add postgressqlstore factory", zap.Error(err)) } - signoz, err := signoz.New( - context.Background(), - config, - zeus.Config(), - httpzeus.NewProviderFactory(), - signoz.NewCacheProviderFactories(), - signoz.NewWebProviderFactories(), - sqlStoreFactories, - signoz.NewTelemetryStoreProviderFactories(), - func(sqlstore sqlstore.SQLStore) user.Module { - return eeuserimpl.NewModule(eeuserimpl.NewStore(sqlstore)) - }, - func(userModule user.Module) user.Handler { - return eeuserimpl.NewHandler(userModule) - }, - ) - if err != nil { - zap.L().Fatal("Failed to create signoz", zap.Error(err)) - } - jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET") if len(jwtSecret) == 0 { @@ -142,6 +124,27 @@ func main() { jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour) + signoz, err := signoz.New( + context.Background(), + config, + zeus.Config(), + httpzeus.NewProviderFactory(), + signoz.NewEmailingProviderFactories(), + signoz.NewCacheProviderFactories(), + signoz.NewWebProviderFactories(), + sqlStoreFactories, + signoz.NewTelemetryStoreProviderFactories(), + func(sqlstore sqlstore.SQLStore, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module { + return eeuserimpl.NewModule(eeuserimpl.NewStore(sqlstore), jwt, emailing, providerSettings) + }, + func(userModule user.Module) user.Handler { + return eeuserimpl.NewHandler(userModule) + }, + ) + if err != nil { + zap.L().Fatal("Failed to create signoz", zap.Error(err)) + } + serverOptions := &app.ServerOptions{ Config: config, SigNoz: signoz, diff --git a/pkg/emailing/config.go b/pkg/emailing/config.go new file mode 100644 index 0000000000..e40e2bd464 --- /dev/null +++ b/pkg/emailing/config.go @@ -0,0 +1,81 @@ +package emailing + +import "github.com/SigNoz/signoz/pkg/factory" + +type Config struct { + Enabled bool `mapstructure:"enabled"` + Templates Templates `mapstructure:"templates"` + SMTP SMTP `mapstructure:"smtp"` +} + +type Templates struct { + Directory string `mapstructure:"directory"` +} + +type SMTP struct { + Address string `mapstructure:"address"` + From string `mapstructure:"from"` + Hello string `mapstructure:"hello"` + Headers map[string]string `mapstructure:"headers"` + Auth SMTPAuth `mapstructure:"auth"` + TLS SMTPTLS `mapstructure:"tls"` +} + +type SMTPAuth struct { + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + Secret string `mapstructure:"secret"` + Identity string `mapstructure:"identity"` +} + +type SMTPTLS struct { + Enabled bool `mapstructure:"enabled"` + InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` + CAFilePath string `mapstructure:"ca_file_path"` + KeyFilePath string `mapstructure:"key_file_path"` + CertFilePath string `mapstructure:"cert_file_path"` +} + +func NewConfigFactory() factory.ConfigFactory { + return factory.NewConfigFactory(factory.MustNewName("emailing"), newConfig) +} + +func newConfig() factory.Config { + return &Config{ + Enabled: false, + Templates: Templates{ + Directory: "/root/templates", + }, + SMTP: SMTP{ + Address: "localhost:25", + From: "", + Hello: "", + Headers: map[string]string{}, + Auth: SMTPAuth{ + Username: "", + Password: "", + Secret: "", + Identity: "", + }, + TLS: SMTPTLS{ + Enabled: false, + InsecureSkipVerify: false, + CAFilePath: "", + KeyFilePath: "", + CertFilePath: "", + }, + }, + } +} + +func (c Config) Validate() error { + return nil +} + +func (c Config) Provider() string { + if c.Enabled { + return "smtp" + } + + return "noop" +} diff --git a/pkg/emailing/emailing.go b/pkg/emailing/emailing.go new file mode 100644 index 0000000000..497a70e1f9 --- /dev/null +++ b/pkg/emailing/emailing.go @@ -0,0 +1,12 @@ +package emailing + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/types/emailtypes" +) + +type Emailing interface { + // Sends an HTML email to the given address with the given subject and template name and data. + SendHTML(context.Context, string, string, emailtypes.TemplateName, map[string]any) error +} diff --git a/pkg/emailing/noopemailing/provider.go b/pkg/emailing/noopemailing/provider.go new file mode 100644 index 0000000000..9384487cf6 --- /dev/null +++ b/pkg/emailing/noopemailing/provider.go @@ -0,0 +1,29 @@ +package noopemailing + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/types/emailtypes" +) + +type provider struct { + settings factory.ScopedProviderSettings +} + +func NewFactory() factory.ProviderFactory[emailing.Emailing, emailing.Config] { + return factory.NewProviderFactory(factory.MustNewName("noop"), New) +} + +func New(ctx context.Context, providerSettings factory.ProviderSettings, config emailing.Config) (emailing.Emailing, error) { + settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/emailing/noopemailing") + return &provider{ + settings: settings, + }, nil +} + +func (provider *provider) SendHTML(ctx context.Context, to string, subject string, templateName emailtypes.TemplateName, data map[string]any) error { + provider.settings.Logger().WarnContext(ctx, "using noop provider, no email will be sent", "to", to, "subject", subject) + return nil +} diff --git a/pkg/emailing/smtpemailing/provider.go b/pkg/emailing/smtpemailing/provider.go new file mode 100644 index 0000000000..66b9489a11 --- /dev/null +++ b/pkg/emailing/smtpemailing/provider.go @@ -0,0 +1,78 @@ +package smtpemailing + +import ( + "context" + "net/mail" + + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/emailing/templatestore/filetemplatestore" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/smtp/client" + "github.com/SigNoz/signoz/pkg/types/emailtypes" +) + +type provider struct { + settings factory.ScopedProviderSettings + store emailtypes.TemplateStore + client *client.Client +} + +func NewFactory() factory.ProviderFactory[emailing.Emailing, emailing.Config] { + return factory.NewProviderFactory(factory.MustNewName("smtp"), New) +} + +func New(ctx context.Context, providerSettings factory.ProviderSettings, config emailing.Config) (emailing.Emailing, error) { + settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/emailing/smtpemailing") + + // Try to create a template store. If it fails, use an empty store. + store, err := filetemplatestore.NewStore(config.Templates.Directory, emailtypes.Templates, settings.Logger()) + if err != nil { + settings.Logger().ErrorContext(ctx, "failed to create template store, using empty store", "error", err) + store = filetemplatestore.NewEmptyStore() + } + + client, err := client.New( + config.SMTP.Address, + settings.Logger(), + client.WithFrom(config.SMTP.From), + client.WithHello(config.SMTP.Hello), + client.WithHeaders(config.SMTP.Headers), + client.WithTLS(client.TLS{ + Enabled: config.SMTP.TLS.Enabled, + InsecureSkipVerify: config.SMTP.TLS.InsecureSkipVerify, + CAFilePath: config.SMTP.TLS.CAFilePath, + KeyFilePath: config.SMTP.TLS.KeyFilePath, + CertFilePath: config.SMTP.TLS.CertFilePath, + }), + client.WithAuth(client.Auth{ + Username: config.SMTP.Auth.Username, + Password: config.SMTP.Auth.Password, + Secret: config.SMTP.Auth.Secret, + Identity: config.SMTP.Auth.Identity, + }), + ) + if err != nil { + return nil, err + } + + return &provider{settings: settings, store: store, client: client}, nil +} + +func (provider *provider) SendHTML(ctx context.Context, to string, subject string, templateName emailtypes.TemplateName, data map[string]any) error { + toAddress, err := mail.ParseAddressList(to) + if err != nil { + return err + } + + template, err := provider.store.Get(ctx, templateName) + if err != nil { + return err + } + + content, err := emailtypes.NewContent(template, data) + if err != nil { + return err + } + + return provider.client.Do(ctx, toAddress, subject, client.ContentTypeHTML, content) +} diff --git a/pkg/emailing/templatestore/filetemplatestore/store.go b/pkg/emailing/templatestore/filetemplatestore/store.go new file mode 100644 index 0000000000..5af518d0f8 --- /dev/null +++ b/pkg/emailing/templatestore/filetemplatestore/store.go @@ -0,0 +1,103 @@ +package filetemplatestore + +import ( + "context" + "html/template" + "log/slog" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/types/emailtypes" +) + +const ( + emailTemplateExt = ".gotmpl" +) + +type store struct { + fs map[emailtypes.TemplateName]*template.Template +} + +func NewStore(baseDir string, templates []emailtypes.TemplateName, logger *slog.Logger) (emailtypes.TemplateStore, error) { + fs := make(map[emailtypes.TemplateName]*template.Template) + fis, err := os.ReadDir(filepath.Clean(baseDir)) + if err != nil { + return nil, err + } + + foundTemplates := make(map[emailtypes.TemplateName]bool) + for _, fi := range fis { + if fi.IsDir() || filepath.Ext(fi.Name()) != emailTemplateExt { + continue + } + + templateName, err := parseTemplateName(fi.Name()) + if err != nil { + continue + } + + if !slices.Contains(templates, templateName) { + continue + } + + t, err := parseTemplateFile(filepath.Join(baseDir, fi.Name()), templateName) + if err != nil { + logger.Error("failed to parse template file", "template", templateName, "path", filepath.Join(baseDir, fi.Name()), "error", err) + continue + } + + fs[templateName] = t + foundTemplates[templateName] = true + } + + if err := checkMissingTemplates(templates, foundTemplates); err != nil { + logger.Error("some templates are missing", "error", err) + } + + return &store{fs: fs}, nil +} + +func NewEmptyStore() emailtypes.TemplateStore { + return &store{fs: make(map[emailtypes.TemplateName]*template.Template)} +} + +func (repository *store) Get(ctx context.Context, name emailtypes.TemplateName) (*template.Template, error) { + template, ok := repository.fs[name] + if !ok { + return nil, errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "cannot find template with name %q", name.StringValue()) + } + + return template.Clone() +} + +func parseTemplateName(fileName string) (emailtypes.TemplateName, error) { + name := strings.TrimSuffix(fileName, emailTemplateExt) + return emailtypes.NewTemplateName(name) +} + +func parseTemplateFile(filePath string, templateName emailtypes.TemplateName) (*template.Template, error) { + contents, err := os.ReadFile(filepath.Clean(filePath)) + if err != nil { + return nil, err + } + + return template.New(templateName.StringValue()).Parse(string(contents)) +} + +func checkMissingTemplates(supportedTemplates []emailtypes.TemplateName, foundTemplates map[emailtypes.TemplateName]bool) error { + var missingTemplates []string + for _, template := range supportedTemplates { + if !foundTemplates[template] { + missingTemplates = append(missingTemplates, template.StringValue()) + } + } + + if len(missingTemplates) > 0 { + return errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "missing email templates: %s", strings.Join(missingTemplates, ", ")) + } + + return nil +} diff --git a/pkg/modules/user/impluser/module.go b/pkg/modules/user/impluser/module.go index 03d29490af..fabc3fb955 100644 --- a/pkg/modules/user/impluser/module.go +++ b/pkg/modules/user/impluser/module.go @@ -1,35 +1,38 @@ package impluser import ( - "bytes" "context" "fmt" - "os" "slices" - "text/template" "time" + "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/modules/user" - "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/query-service/telemetry" - smtpservice "github.com/SigNoz/signoz/pkg/query-service/utils/smtpService" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/emailtypes" "github.com/SigNoz/signoz/pkg/valuer" - "go.uber.org/zap" ) type Module struct { - store types.UserStore - JWT *authtypes.JWT + store types.UserStore + jwt *authtypes.JWT + emailing emailing.Emailing + settings factory.ScopedProviderSettings } // This module is a WIP, don't take inspiration from this. -func NewModule(store types.UserStore) user.Module { - jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET") - jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour) - return &Module{store: store, JWT: jwt} +func NewModule(store types.UserStore, jwt *authtypes.JWT, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module { + settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser") + return &Module{ + store: store, + jwt: jwt, + emailing: emailing, + settings: settings, + } } // CreateBulk implements invite.Module. @@ -84,47 +87,20 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID, userID string, bul "invited user email": invites[i].Email, }, creator.Email, true, false) - // send email if SMTP is enabled - if os.Getenv("SMTP_ENABLED") == "true" && bulkInvites.Invites[i].FrontendBaseUrl != "" { - m.inviteEmail(&bulkInvites.Invites[i], creator.Email, creator.DisplayName, invites[i].Token) + if err := m.emailing.SendHTML(ctx, invites[i].Email, "You are invited to join a team in SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{ + "CustomerName": invites[i].Name, + "InviterName": creator.DisplayName, + "InviterEmail": creator.Email, + "Link": fmt.Sprintf("%s/signup?token=%s", bulkInvites.Invites[i].FrontendBaseUrl, invites[i].Token), + }); err != nil { + m.settings.Logger().ErrorContext(ctx, "failed to send email", "error", err) } + } return invites, nil } -func (m *Module) inviteEmail(req *types.PostableInvite, creatorEmail, creatorName, token string) { - smtp := smtpservice.GetInstance() - data := types.InviteEmailData{ - CustomerName: req.Name, - InviterName: creatorName, - InviterEmail: creatorEmail, - Link: fmt.Sprintf("%s/signup?token=%s", req.FrontendBaseUrl, token), - } - - tmpl, err := template.ParseFiles(constants.InviteEmailTemplate) - if err != nil { - zap.L().Error("failed to send email", zap.Error(err)) - return - } - - var body bytes.Buffer - if err := tmpl.Execute(&body, data); err != nil { - zap.L().Error("failed to send email", zap.Error(err)) - return - } - - err = smtp.SendEmail( - req.Email, - creatorName+" has invited you to their team in SigNoz", - body.String(), - ) - if err != nil { - zap.L().Error("failed to send email", zap.Error(err)) - return - } -} - func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) { return m.store.ListInvite(ctx, orgID) } @@ -282,7 +258,7 @@ func (m *Module) UpdatePassword(ctx context.Context, userID string, password str func (m *Module) GetAuthenticatedUser(ctx context.Context, orgID, email, password, refreshToken string) (*types.User, error) { if refreshToken != "" { // parse the refresh token - claims, err := m.JWT.Claims(refreshToken) + claims, err := m.jwt.Claims(refreshToken) if err != nil { return nil, err } @@ -361,12 +337,12 @@ func (m *Module) GetJWTForUser(ctx context.Context, user *types.User) (types.Get return types.GettableUserJwt{}, err } - accessJwt, accessClaims, err := m.JWT.AccessToken(user.OrgID, user.ID.String(), user.Email, role) + accessJwt, accessClaims, err := m.jwt.AccessToken(user.OrgID, user.ID.String(), user.Email, role) if err != nil { return types.GettableUserJwt{}, err } - refreshJwt, refreshClaims, err := m.JWT.RefreshToken(user.OrgID, user.ID.String(), user.Email, role) + refreshJwt, refreshClaims, err := m.jwt.RefreshToken(user.OrgID, user.ID.String(), user.Email, role) if err != nil { return types.GettableUserJwt{}, err } diff --git a/pkg/query-service/app/cloudintegrations/controller_test.go b/pkg/query-service/app/cloudintegrations/controller_test.go index 9c66d0530b..59218348de 100644 --- a/pkg/query-service/app/cloudintegrations/controller_test.go +++ b/pkg/query-service/app/cloudintegrations/controller_test.go @@ -4,6 +4,9 @@ import ( "context" "testing" + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/emailing/noopemailing" + "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/user" @@ -22,7 +25,9 @@ func TestRegenerateConnectionUrlWithUpdatedConfig(t *testing.T) { require.NoError(err) organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + providerSettings := instrumentationtest.New().ToProviderSettings() + emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) + userModule := impluser.NewModule(impluser.NewStore(sqlStore), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) @@ -70,7 +75,9 @@ func TestAgentCheckIns(t *testing.T) { controller, err := NewController(sqlStore) require.NoError(err) organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + providerSettings := instrumentationtest.New().ToProviderSettings() + emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) + userModule := impluser.NewModule(impluser.NewStore(sqlStore), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) @@ -158,7 +165,9 @@ func TestCantDisconnectNonExistentAccount(t *testing.T) { require.NoError(err) organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + providerSettings := instrumentationtest.New().ToProviderSettings() + emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) + userModule := impluser.NewModule(impluser.NewStore(sqlStore), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) @@ -178,7 +187,9 @@ func TestConfigureService(t *testing.T) { require.NoError(err) organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + providerSettings := instrumentationtest.New().ToProviderSettings() + emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) + userModule := impluser.NewModule(impluser.NewStore(sqlStore), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) diff --git a/pkg/query-service/app/integrations/manager_test.go b/pkg/query-service/app/integrations/manager_test.go index f6e8d0a12a..0c6bd1c51c 100644 --- a/pkg/query-service/app/integrations/manager_test.go +++ b/pkg/query-service/app/integrations/manager_test.go @@ -4,6 +4,9 @@ import ( "context" "testing" + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/emailing/noopemailing" + "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/user/impluser" _ "github.com/mattn/go-sqlite3" @@ -17,7 +20,9 @@ func TestIntegrationLifecycle(t *testing.T) { ctx := context.Background() organizationModule := implorganization.NewModule(implorganization.NewStore(store)) - userModule := impluser.NewModule(impluser.NewStore(store)) + providerSettings := instrumentationtest.New().ToProviderSettings() + emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) + userModule := impluser.NewModule(impluser.NewStore(store), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) if apiErr != nil { t.Fatalf("could not create test user: %v", apiErr) diff --git a/pkg/query-service/constants/constants.go b/pkg/query-service/constants/constants.go index a9c7e50656..21850a53ac 100644 --- a/pkg/query-service/constants/constants.go +++ b/pkg/query-service/constants/constants.go @@ -52,7 +52,8 @@ var TELEMETRY_HEART_BEAT_DURATION_MINUTES = GetOrDefaultEnvInt("TELEMETRY_HEART_ var TELEMETRY_ACTIVE_USER_DURATION_MINUTES = GetOrDefaultEnvInt("TELEMETRY_ACTIVE_USER_DURATION_MINUTES", 360) -var InviteEmailTemplate = GetOrDefaultEnv("INVITE_EMAIL_TEMPLATE", "/root/templates/invitation_email_template.html") +// Deprecated: Use the new emailing service instead +var InviteEmailTemplate = GetOrDefaultEnv("INVITE_EMAIL_TEMPLATE", "/root/templates/invitation_email.gotmpl") var MetricsExplorerClickhouseThreads = GetOrDefaultEnvInt("METRICS_EXPLORER_CLICKHOUSE_THREADS", 8) var UpdatedMetricsMetadataCachePrefix = GetOrDefaultEnv("METRICS_UPDATED_METADATA_CACHE_KEY", "UPDATED_METRICS_METADATA") diff --git a/pkg/query-service/main.go b/pkg/query-service/main.go index 1c4d891f83..7c4ba52dbb 100644 --- a/pkg/query-service/main.go +++ b/pkg/query-service/main.go @@ -9,6 +9,8 @@ import ( "github.com/SigNoz/signoz/pkg/config" "github.com/SigNoz/signoz/pkg/config/envprovider" "github.com/SigNoz/signoz/pkg/config/fileprovider" + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/app" @@ -102,26 +104,6 @@ func main() { version.Info.PrettyPrint(config.Version) - signoz, err := signoz.New( - context.Background(), - config, - zeus.Config{}, - noopzeus.NewProviderFactory(), - signoz.NewCacheProviderFactories(), - signoz.NewWebProviderFactories(), - signoz.NewSQLStoreProviderFactories(), - signoz.NewTelemetryStoreProviderFactories(), - func(sqlstore sqlstore.SQLStore) user.Module { - return impluser.NewModule(impluser.NewStore(sqlstore)) - }, - func(userModule user.Module) user.Handler { - return impluser.NewHandler(userModule) - }, - ) - if err != nil { - zap.L().Fatal("Failed to create signoz", zap.Error(err)) - } - // Read the jwt secret key jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET") @@ -133,6 +115,27 @@ func main() { jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour) + signoz, err := signoz.New( + context.Background(), + config, + zeus.Config{}, + noopzeus.NewProviderFactory(), + signoz.NewEmailingProviderFactories(), + signoz.NewCacheProviderFactories(), + signoz.NewWebProviderFactories(), + signoz.NewSQLStoreProviderFactories(), + signoz.NewTelemetryStoreProviderFactories(), + func(sqlstore sqlstore.SQLStore, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module { + return impluser.NewModule(impluser.NewStore(sqlstore), jwt, emailing, providerSettings) + }, + func(userModule user.Module) user.Handler { + return impluser.NewHandler(userModule) + }, + ) + if err != nil { + zap.L().Fatal("Failed to create signoz", zap.Error(err)) + } + serverOptions := &app.ServerOptions{ Config: config, HTTPHostPort: constants.HTTPHostPort, diff --git a/pkg/query-service/tests/integration/filter_suggestions_test.go b/pkg/query-service/tests/integration/filter_suggestions_test.go index ece980695c..781e453cdf 100644 --- a/pkg/query-service/tests/integration/filter_suggestions_test.go +++ b/pkg/query-service/tests/integration/filter_suggestions_test.go @@ -1,6 +1,7 @@ package tests import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -8,9 +9,13 @@ import ( "slices" "strings" "testing" + "time" + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/emailing/noopemailing" "github.com/SigNoz/signoz/pkg/modules/quickfilter" quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" + "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" @@ -303,7 +308,10 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { reader, mockClickhouse := NewMockClickhouseReader(t, testDB) mockClickhouse.MatchExpectationsInOrder(false) - userModule := impluser.NewModule(impluser.NewStore(testDB)) + providerSettings := instrumentationtest.New().ToProviderSettings() + emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) + jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour) + userModule := impluser.NewModule(impluser.NewStore(testDB), jwt, emailing, providerSettings) userHandler := impluser.NewHandler(userModule) modules := signoz.NewModules(testDB, userModule) quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) diff --git a/pkg/query-service/tests/integration/logparsingpipeline_test.go b/pkg/query-service/tests/integration/logparsingpipeline_test.go index bca1414ead..a28b91fb32 100644 --- a/pkg/query-service/tests/integration/logparsingpipeline_test.go +++ b/pkg/query-service/tests/integration/logparsingpipeline_test.go @@ -1,6 +1,7 @@ package tests import ( + "context" "encoding/json" "fmt" "io" @@ -8,7 +9,11 @@ import ( "runtime/debug" "strings" "testing" + "time" + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/emailing/noopemailing" + "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/quickfilter" quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" @@ -27,6 +32,7 @@ import ( "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/pipelinetypes" "github.com/google/uuid" "github.com/gorilla/mux" @@ -476,7 +482,10 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli t.Fatalf("could not create a logparsingpipelines controller: %v", err) } - userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + providerSettings := instrumentationtest.New().ToProviderSettings() + emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) + jwt := authtypes.NewJWT("", 10*time.Minute, 30*time.Minute) + userModule := impluser.NewModule(impluser.NewStore(sqlStore), jwt, emailing, providerSettings) userHandler := impluser.NewHandler(userModule) modules := signoz.NewModules(sqlStore, userModule) handlers := signoz.NewHandlers(modules, userHandler) diff --git a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go index a5b34980f5..0d3a93ac1d 100644 --- a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go @@ -1,6 +1,7 @@ package tests import ( + "context" "encoding/json" "fmt" "net/http" @@ -8,8 +9,11 @@ import ( "testing" "time" + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/emailing/noopemailing" "github.com/SigNoz/signoz/pkg/modules/quickfilter" quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" + "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" @@ -366,7 +370,10 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI reader, mockClickhouse := NewMockClickhouseReader(t, testDB) mockClickhouse.MatchExpectationsInOrder(false) - userModule := impluser.NewModule(impluser.NewStore(testDB)) + providerSettings := instrumentationtest.New().ToProviderSettings() + emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) + jwt := authtypes.NewJWT("", 10*time.Minute, 30*time.Minute) + userModule := impluser.NewModule(impluser.NewStore(testDB), jwt, emailing, providerSettings) userHandler := impluser.NewHandler(userModule) modules := signoz.NewModules(testDB, userModule) handlers := signoz.NewHandlers(modules, userHandler) diff --git a/pkg/query-service/tests/integration/signoz_integrations_test.go b/pkg/query-service/tests/integration/signoz_integrations_test.go index 048fa294a9..4111d6df42 100644 --- a/pkg/query-service/tests/integration/signoz_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_integrations_test.go @@ -1,6 +1,7 @@ package tests import ( + "context" "encoding/json" "fmt" "net/http" @@ -8,6 +9,8 @@ import ( "testing" "time" + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/emailing/noopemailing" "github.com/SigNoz/signoz/pkg/modules/quickfilter" quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" @@ -26,6 +29,7 @@ import ( "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/pipelinetypes" mockhouse "github.com/srikanthccv/ClickHouse-go-mock" "github.com/stretchr/testify/require" @@ -572,7 +576,10 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration t.Fatalf("could not create cloud integrations controller: %v", err) } - userModule := impluser.NewModule(impluser.NewStore(testDB)) + providerSettings := instrumentationtest.New().ToProviderSettings() + emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) + jwt := authtypes.NewJWT("", 10*time.Minute, 30*time.Minute) + userModule := impluser.NewModule(impluser.NewStore(testDB), jwt, emailing, providerSettings) userHandler := impluser.NewHandler(userModule) modules := signoz.NewModules(testDB, userModule) handlers := signoz.NewHandlers(modules, userHandler) diff --git a/pkg/query-service/utils/smtpService/smtp.go b/pkg/query-service/utils/smtpService/smtp.go deleted file mode 100644 index 454c5f022a..0000000000 --- a/pkg/query-service/utils/smtpService/smtp.go +++ /dev/null @@ -1,57 +0,0 @@ -package smtpservice - -import ( - "net/smtp" - "os" - "strings" - "sync" -) - -type SMTP struct { - Host string - Port string - Username string - Password string - From string -} - -var smtpInstance *SMTP -var once sync.Once - -func New() *SMTP { - return &SMTP{ - Host: os.Getenv("SMTP_HOST"), - Port: os.Getenv("SMTP_PORT"), - Username: os.Getenv("SMTP_USERNAME"), - Password: os.Getenv("SMTP_PASSWORD"), - From: os.Getenv("SMTP_FROM"), - } -} - -func GetInstance() *SMTP { - once.Do(func() { - smtpInstance = New() - }) - return smtpInstance -} - -func (s *SMTP) SendEmail(to, subject, body string) error { - - msgString := "From: " + s.From + "\r\n" + - "To: " + to + "\r\n" + - "Subject: " + subject + "\r\n" + - "MIME-Version: 1.0\r\n" + - "Content-Type: text/html; charset=UTF-8\r\n" + - "\r\n" + - body - - msg := []byte(msgString) - - addr := s.Host + ":" + s.Port - if s.Password == "" || s.Username == "" { - return smtp.SendMail(addr, nil, s.From, strings.Split(to, ","), msg) - } else { - auth := smtp.PlainAuth("", s.Username, s.Password, s.Host) - return smtp.SendMail(addr, auth, s.From, strings.Split(to, ","), msg) - } -} diff --git a/pkg/signoz/config.go b/pkg/signoz/config.go index de0fe8eeca..4fb3bbf142 100644 --- a/pkg/signoz/config.go +++ b/pkg/signoz/config.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "path" "reflect" "time" @@ -12,6 +13,7 @@ import ( "github.com/SigNoz/signoz/pkg/apiserver" "github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/config" + "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/instrumentation" "github.com/SigNoz/signoz/pkg/prometheus" @@ -57,6 +59,9 @@ type Config struct { // Alertmanager config Alertmanager alertmanager.Config `mapstructure:"alertmanager" yaml:"alertmanager"` + + // Emailing config + Emailing emailing.Config `mapstructure:"emailing" yaml:"emailing"` } // DeprecatedFlags are the flags that are deprecated and scheduled for removal. @@ -80,6 +85,7 @@ func NewConfig(ctx context.Context, resolverConfig config.ResolverConfig, deprec telemetrystore.NewConfigFactory(), prometheus.NewConfigFactory(), alertmanager.NewConfigFactory(), + emailing.NewConfigFactory(), } conf, err := config.New(ctx, resolverConfig, configFactories) @@ -186,4 +192,42 @@ func mergeAndEnsureBackwardCompatibility(config *Config, deprecatedFlags Depreca if deprecatedFlags.Config != "" { fmt.Println("[Deprecated] flag --config is deprecated for passing prometheus config. The flag will be used for passing the entire SigNoz config. More details can be found at https://github.com/SigNoz/signoz/issues/6805.") } + + if os.Getenv("INVITE_EMAIL_TEMPLATE") != "" { + fmt.Println("[Deprecated] env INVITE_EMAIL_TEMPLATE is deprecated and scheduled for removal. Please use SIGNOZ_EMAILING_TEMPLATES_DIRECTORY instead.") + config.Emailing.Templates.Directory = path.Dir(os.Getenv("INVITE_EMAIL_TEMPLATE")) + } + + if os.Getenv("SMTP_ENABLED") != "" { + fmt.Println("[Deprecated] env SMTP_ENABLED is deprecated and scheduled for removal. Please use SIGNOZ_EMAILING_ENABLED instead.") + config.Emailing.Enabled = os.Getenv("SMTP_ENABLED") == "true" + } + + if os.Getenv("SMTP_HOST") != "" { + fmt.Println("[Deprecated] env SMTP_HOST is deprecated and scheduled for removal. Please use SIGNOZ_EMAILING_ADDRESS instead.") + if os.Getenv("SMTP_PORT") != "" { + config.Emailing.SMTP.Address = os.Getenv("SMTP_HOST") + ":" + os.Getenv("SMTP_PORT") + } else { + config.Emailing.SMTP.Address = os.Getenv("SMTP_HOST") + } + } + + if os.Getenv("SMTP_PORT") != "" { + fmt.Println("[Deprecated] env SMTP_PORT is deprecated and scheduled for removal. Please use SIGNOZ_EMAILING_ADDRESS instead.") + } + + if os.Getenv("SMTP_USERNAME") != "" { + fmt.Println("[Deprecated] env SMTP_USERNAME is deprecated and scheduled for removal. Please use SIGNOZ_EMAILING_AUTH_USERNAME instead.") + config.Emailing.SMTP.Auth.Username = os.Getenv("SMTP_USERNAME") + } + + if os.Getenv("SMTP_PASSWORD") != "" { + fmt.Println("[Deprecated] env SMTP_PASSWORD is deprecated and scheduled for removal. Please use SIGNOZ_EMAILING_AUTH_PASSWORD instead.") + config.Emailing.SMTP.Auth.Password = os.Getenv("SMTP_PASSWORD") + } + + if os.Getenv("SMTP_FROM") != "" { + fmt.Println("[Deprecated] env SMTP_FROM is deprecated and scheduled for removal. Please use SIGNOZ_EMAILING_FROM instead.") + config.Emailing.SMTP.From = os.Getenv("SMTP_FROM") + } } diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index 80032170e1..a1aed180c9 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -7,6 +7,9 @@ import ( "github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/cache/memorycache" "github.com/SigNoz/signoz/pkg/cache/rediscache" + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/emailing/noopemailing" + "github.com/SigNoz/signoz/pkg/emailing/smtpemailing" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheus" @@ -99,3 +102,10 @@ func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM signozalertmanager.NewFactory(sqlstore), ) } + +func NewEmailingProviderFactories() factory.NamedMap[factory.ProviderFactory[emailing.Emailing, emailing.Config]] { + return factory.MustNewNamedMap( + noopemailing.NewFactory(), + smtpemailing.NewFactory(), + ) +} diff --git a/pkg/signoz/provider_test.go b/pkg/signoz/provider_test.go index 6ecd065826..7245d50f66 100644 --- a/pkg/signoz/provider_test.go +++ b/pkg/signoz/provider_test.go @@ -42,4 +42,8 @@ func TestNewProviderFactories(t *testing.T) { assert.NotPanics(t, func() { NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)) }) + + assert.NotPanics(t, func() { + NewEmailingProviderFactories() + }) } diff --git a/pkg/signoz/signoz.go b/pkg/signoz/signoz.go index 18a8e627dd..d72a1c18cd 100644 --- a/pkg/signoz/signoz.go +++ b/pkg/signoz/signoz.go @@ -5,6 +5,7 @@ import ( "github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/cache" + "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/instrumentation" "github.com/SigNoz/signoz/pkg/modules/user" @@ -29,6 +30,7 @@ type SigNoz struct { Prometheus prometheus.Prometheus Alertmanager alertmanager.Alertmanager Zeus zeus.Zeus + Emailing emailing.Emailing Modules Modules Handlers Handlers } @@ -38,12 +40,13 @@ func New( config Config, zeusConfig zeus.Config, zeusProviderFactory factory.ProviderFactory[zeus.Zeus, zeus.Config], + emailingProviderFactories factory.NamedMap[factory.ProviderFactory[emailing.Emailing, emailing.Config]], cacheProviderFactories factory.NamedMap[factory.ProviderFactory[cache.Cache, cache.Config]], webProviderFactories factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]], sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]], telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]], - diModules func(sqlstore.SQLStore) user.Module, - diHandlers func(user.Module) user.Handler, + userModuleFactory func(sqlstore sqlstore.SQLStore, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module, + userHandlerFactory func(user.Module) user.Handler, ) (*SigNoz, error) { // Initialize instrumentation instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz") @@ -68,6 +71,18 @@ func New( return nil, err } + // Initialize emailing from the available emailing provider factories + emailing, err := factory.NewProviderFromNamedMap( + ctx, + providerSettings, + config.Emailing, + emailingProviderFactories, + config.Emailing.Provider(), + ) + if err != nil { + return nil, err + } + // Initialize cache from the available cache provider factories cache, err := factory.NewProviderFromNamedMap( ctx, @@ -156,8 +171,8 @@ func New( return nil, err } - userModule := diModules(sqlstore) - userHandler := diHandlers(userModule) + userModule := userModuleFactory(sqlstore, emailing, providerSettings) + userHandler := userHandlerFactory(userModule) // Initialize all modules modules := NewModules(sqlstore, userModule) @@ -184,6 +199,7 @@ func New( Prometheus: prometheus, Alertmanager: alertmanager, Zeus: zeus, + Emailing: emailing, Modules: modules, Handlers: handlers, }, nil diff --git a/pkg/smtp/client/auth.go b/pkg/smtp/client/auth.go new file mode 100644 index 0000000000..6385f9c135 --- /dev/null +++ b/pkg/smtp/client/auth.go @@ -0,0 +1,34 @@ +package client + +import ( + "errors" + "net/smtp" + "strings" +) + +type loginAuth struct { + username string + password string +} + +func LoginAuth(username, password string) smtp.Auth { + return &loginAuth{username, password} +} + +func (auth *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte{}, nil +} + +func (auth *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch strings.ToLower(string(fromServer)) { + case "username:": + return []byte(auth.username), nil + case "password:": + return []byte(auth.password), nil + default: + return nil, errors.New("unexpected server challenge") + } + } + return nil, nil +} diff --git a/pkg/smtp/client/content.go b/pkg/smtp/client/content.go new file mode 100644 index 0000000000..77449c3134 --- /dev/null +++ b/pkg/smtp/client/content.go @@ -0,0 +1,10 @@ +package client + +import "github.com/SigNoz/signoz/pkg/valuer" + +var ( + ContentTypeText = ContentType{valuer.NewString("text/plain")} + ContentTypeHTML = ContentType{valuer.NewString("text/html")} +) + +type ContentType struct{ valuer.String } diff --git a/pkg/smtp/client/option.go b/pkg/smtp/client/option.go new file mode 100644 index 0000000000..dc50de8735 --- /dev/null +++ b/pkg/smtp/client/option.go @@ -0,0 +1,56 @@ +package client + +type Auth struct { + Username string + Password string + Identity string + Secret string +} + +type TLS struct { + Enabled bool + InsecureSkipVerify bool + CAFilePath string + KeyFilePath string + CertFilePath string +} + +type options struct { + from string + headers map[string]string + hello string + auth Auth + tls TLS +} + +type Option func(*options) + +func WithFrom(s string) Option { + return func(o *options) { + o.from = s + } +} + +func WithHeaders(m map[string]string) Option { + return func(o *options) { + o.headers = m + } +} + +func WithHello(s string) Option { + return func(o *options) { + o.hello = s + } +} + +func WithAuth(auth Auth) Option { + return func(o *options) { + o.auth = auth + } +} + +func WithTLS(tls TLS) Option { + return func(o *options) { + o.tls = tls + } +} diff --git a/pkg/smtp/client/smtp.go b/pkg/smtp/client/smtp.go new file mode 100644 index 0000000000..991db0c867 --- /dev/null +++ b/pkg/smtp/client/smtp.go @@ -0,0 +1,360 @@ +package client + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "log/slog" + "math/rand" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net" + "net/mail" + "net/smtp" + "net/textproto" + "os" + "strings" + "sync" + "time" +) + +type Client struct { + logger *slog.Logger + address string + host string + port string + from *mail.Address + headers map[string]string + hello string + auth Auth + tls TLS + tlsConfig *tls.Config +} + +func New(address string, logger *slog.Logger, opts ...Option) (*Client, error) { + clientOpts := options{ + from: "signoz@signoz.localhost", + headers: make(map[string]string), + auth: Auth{}, + tls: TLS{ + Enabled: false, + }, + hello: "", + } + + for _, opt := range opts { + opt(&clientOpts) + } + + from, err := mail.ParseAddress(clientOpts.from) + if err != nil { + return nil, fmt.Errorf("parse 'from' address: %w", err) + } + + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("parse 'address': %w", err) + } + + if clientOpts.headers == nil { + clientOpts.headers = make(map[string]string) + } + clientOpts.headers["From"] = from.String() + + tls, err := newTLSConfig(clientOpts.tls, host) + if err != nil { + return nil, fmt.Errorf("create TLS config: %w", err) + } + + return &Client{ + logger: logger, + address: address, + host: host, + port: port, + from: from, + headers: clientOpts.headers, + hello: clientOpts.hello, + auth: clientOpts.auth, + tls: clientOpts.tls, + tlsConfig: tls, + }, nil +} + +func (c *Client) Do(ctx context.Context, tos []*mail.Address, subject string, contentType ContentType, body []byte) error { + var ( + smtpClient *smtp.Client + conn net.Conn + err error + success = false + ) + + // Dial the SMTP server. + conn, err = c.dial(ctx) + if err != nil { + return err + } + + // Create a new SMTP client. + smtpClient, err = smtp.NewClient(conn, c.host) + if err != nil { + conn.Close() + return fmt.Errorf("failed to create SMTP client: %w", err) + } + + // Try to clean up after ourselves but don't log anything if something has failed. + defer func() { + if err := smtpClient.Quit(); success && err != nil { + c.logger.Warn("failed to close SMTP connection", "error", err) + } + }() + + // Send the EHLO command. + if c.hello != "" { + err = smtpClient.Hello(c.hello) + if err != nil { + return fmt.Errorf("failed to send EHLO command: %w", err) + } + } + + // If TLS is not enabled, check if the server supports STARTTLS. + if !c.tls.Enabled { + if ok, _ := smtpClient.Extension("STARTTLS"); ok { + if err := smtpClient.StartTLS(c.tlsConfig); err != nil { + return fmt.Errorf("failed to send STARTTLS command: %w", err) + } + } + } + + // If the server supports the AUTH command, + if ok, mech := smtpClient.Extension("AUTH"); ok { + // If the username is set, find the appropriate authentication mechanism. + if c.auth.Username != "" { + auth, err := c.smtpAuth(ctx, mech) + if err != nil { + return fmt.Errorf("failed to find auth mechanism: %w", err) + } + + // Send the AUTH command. + if err := smtpClient.Auth(auth); err != nil { + return fmt.Errorf("failed to auth: %T: %w", auth, err) + } + } + } + + // Send the MAIL command. + if err = smtpClient.Mail(c.from.Address); err != nil { + return fmt.Errorf("failed to send MAIL command: %w", err) + } + + // Send the RCPT command for each recipient. + for _, addr := range tos { + if err = smtpClient.Rcpt(addr.Address); err != nil { + return fmt.Errorf("failed to send RCPT command: %w", err) + } + } + + // Send the email headers and body. + message, err := smtpClient.Data() + if err != nil { + return fmt.Errorf("failed to send DATA command: %w", err) + } + + closeOnce := sync.OnceValue(func() error { + return message.Close() + }) + + // Close the message when this method exits in order to not leak resources. Even though we're calling this explicitly + // further down, the method may exit before then. + defer func() { + // If we try close an already-closed writer, it'll send a subsequent request to the server which is invalid. + _ = closeOnce() + }() + + buffer := &bytes.Buffer{} + for header, value := range c.headers { + fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", value)) + } + + at := strings.LastIndex(c.from.Address, "@") + if at >= 0 { + fmt.Fprintf(buffer, "Message-Id: %s\r\n", fmt.Sprintf("<%d.%d@%s>", time.Now().UnixNano(), rand.Uint64(), c.from.Address[at+1:])) + } + + multipartBuffer := &bytes.Buffer{} + multipartWriter := multipart.NewWriter(multipartBuffer) + + tosAsStrings := make([]string, len(tos)) + for i, to := range tos { + tosAsStrings[i] = to.String() + } + fmt.Fprintf(buffer, "To: %s\r\n", strings.Join(tosAsStrings, ",")) + fmt.Fprintf(buffer, "Subject: %s\r\n", subject) + fmt.Fprintf(buffer, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) + fmt.Fprintf(buffer, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary()) + fmt.Fprintf(buffer, "MIME-Version: 1.0\r\n\r\n") + + _, err = message.Write(buffer.Bytes()) + if err != nil { + return fmt.Errorf("failed to write headers: %w", err) + } + + // Text template + if contentType == ContentTypeText { + w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ + "Content-Transfer-Encoding": {"quoted-printable"}, + "Content-Type": {"text/plain; charset=UTF-8"}, + }) + if err != nil { + return fmt.Errorf("failed to create part for text template: %w", err) + } + + qw := quotedprintable.NewWriter(w) + _, err = qw.Write([]byte(body)) + if err != nil { + return fmt.Errorf("failed to write text part: %w", err) + } + err = qw.Close() + if err != nil { + return fmt.Errorf("failed to close text part: %w", err) + } + } + + // Html template + // Preferred alternative placed last per section 5.1.4 of RFC 2046 + // https://www.ietf.org/rfc/rfc2046.txt + if contentType == ContentTypeHTML { + w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ + "Content-Transfer-Encoding": {"quoted-printable"}, + "Content-Type": {"text/html; charset=UTF-8"}, + }) + if err != nil { + return fmt.Errorf("failed to create part for html template: %w", err) + } + + qw := quotedprintable.NewWriter(w) + _, err = qw.Write([]byte(body)) + if err != nil { + return fmt.Errorf("failed to write HTML part: %w", err) + } + err = qw.Close() + if err != nil { + return fmt.Errorf("failed to close HTML part: %w", err) + } + } + + err = multipartWriter.Close() + if err != nil { + return fmt.Errorf("failed to close multipartWriter: %w", err) + } + + _, err = message.Write(multipartBuffer.Bytes()) + if err != nil { + return fmt.Errorf("failed to write body buffer: %w", err) + } + + // Complete the message and await response. + if err = closeOnce(); err != nil { + return fmt.Errorf("failed to deliver: %w", err) + } + + success = true + return nil +} + +// auth resolves a string of authentication mechanisms. +func (c *Client) smtpAuth(_ context.Context, mechs string) (smtp.Auth, error) { + username := c.auth.Username + + var errs []error + for _, mech := range strings.Split(mechs, " ") { + switch mech { + case "CRAM-MD5": + secret := c.auth.Secret + if secret == "" { + errs = append(errs, errors.New("missing secret for CRAM-MD5 auth mechanism")) + continue + } + return smtp.CRAMMD5Auth(username, secret), nil + + case "PLAIN": + password := c.auth.Password + if password == "" { + errs = append(errs, errors.New("missing password for PLAIN auth mechanism")) + continue + } + identity := c.auth.Identity + + return smtp.PlainAuth(identity, username, password, c.host), nil + case "LOGIN": + password := c.auth.Password + if password == "" { + errs = append(errs, errors.New("missing password for LOGIN auth mechanism")) + continue + } + + return LoginAuth(username, password), nil + } + } + + if len(errs) == 0 { + errs = append(errs, errors.New("unknown auth mechanism: "+mechs)) + } + + return nil, errors.Join(errs...) +} + +func (c *Client) dial(ctx context.Context) (net.Conn, error) { + var ( + conn net.Conn + err error + ) + + if c.tls.Enabled || c.port == "465" { + conn, err = tls.Dial("tcp", c.address, c.tlsConfig) + if err != nil { + return nil, fmt.Errorf("failed to establish TLS connection to server: %w", err) + } + + return conn, nil + } + + var d net.Dialer + conn, err = d.DialContext(ctx, "tcp", c.address) + if err != nil { + return nil, fmt.Errorf("failed to establish connection to server: %w", err) + } + + return conn, nil +} + +func newTLSConfig(config TLS, serverName string) (*tls.Config, error) { + tlsConfig := &tls.Config{ + InsecureSkipVerify: config.InsecureSkipVerify, + ServerName: serverName, + } + + if config.CertFilePath != "" { + cert, err := tls.LoadX509KeyPair(config.CertFilePath, config.KeyFilePath) + if err != nil { + return nil, fmt.Errorf("failed to load cert or key file: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + if config.CAFilePath != "" { + ca, err := os.ReadFile(config.CAFilePath) + if err != nil { + return nil, fmt.Errorf("failed to load CA file: %w", err) + } + tlsConfig.RootCAs = x509.NewCertPool() + if !tlsConfig.RootCAs.AppendCertsFromPEM(ca) { + return nil, fmt.Errorf("failed to append CA file: %s", config.CAFilePath) + } + } + + return tlsConfig, nil +} diff --git a/pkg/types/emailtypes/template.go b/pkg/types/emailtypes/template.go new file mode 100644 index 0000000000..68b76bd4ee --- /dev/null +++ b/pkg/types/emailtypes/template.go @@ -0,0 +1,45 @@ +package emailtypes + +import ( + "bytes" + "context" + "html/template" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/valuer" +) + +var ( + // Templates is a list of all the templates that are supported by the emailing service. + // This list should be updated whenever a new template is added. + Templates = []TemplateName{TemplateNameInvitationEmail} +) + +var ( + TemplateNameInvitationEmail = TemplateName{valuer.NewString("invitation_email")} +) + +type TemplateName struct{ valuer.String } + +func NewTemplateName(name string) (TemplateName, error) { + switch name { + case TemplateNameInvitationEmail.StringValue(): + return TemplateNameInvitationEmail, nil + default: + return TemplateName{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid template name: %s", name) + } +} + +func NewContent(template *template.Template, data map[string]any) ([]byte, error) { + buf := bytes.NewBuffer(nil) + err := template.Execute(buf, data) + if err != nil { + return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute template") + } + + return buf.Bytes(), nil +} + +type TemplateStore interface { + Get(context.Context, TemplateName) (*template.Template, error) +} diff --git a/templates/email/invitation_email_template.html b/templates/email/invitation_email.gotmpl similarity index 100% rename from templates/email/invitation_email_template.html rename to templates/email/invitation_email.gotmpl