feat(emailing): add smtp and emailing (#7993)

* feat(emailing): initial commit for emailing

* feat(emailing): implement emailing

* test(integration): fix tests

* fix(emailing): fix directory path

* fix(emailing): fix email template path

* fix(emailing): copy from go-gomail

* fix(emailing): copy from go-gomail

* fix(emailing): fix smtp bugs

* test(integration): fix tests

* feat(emailing): let missing templates passthrough

* feat(emailing): let missing templates passthrough

* feat(smtp): refactor and beautify

* test(integration): fix tests

* docs(smtp): fix incorrect grammer

* feat(smtp): add to header

* feat(smtp): remove comments

* chore(smtp): address comments

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
This commit is contained in:
Vibhu Pandey 2025-05-23 00:01:52 +05:30 committed by GitHub
parent a1c7a948fa
commit 9e13245d1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1061 additions and 172 deletions

View File

@ -170,3 +170,40 @@ alertmanager:
analytics: analytics:
# Whether to enable analytics. # Whether to enable analytics.
enabled: false 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:

View File

@ -7,7 +7,9 @@ import (
"strings" "strings"
"github.com/SigNoz/signoz/ee/query-service/constants" "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/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user"
baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser" baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types"
@ -22,8 +24,8 @@ type Module struct {
store types.UserStore store types.UserStore
} }
func NewModule(store types.UserStore) user.Module { func NewModule(store types.UserStore, jwt *authtypes.JWT, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module {
baseModule := baseimpl.NewModule(store) baseModule := baseimpl.NewModule(store, jwt, emailing, providerSettings)
return &Module{ return &Module{
Module: baseModule, Module: baseModule,
store: store, store: store,

View File

@ -12,7 +12,6 @@ import (
"github.com/SigNoz/signoz/ee/query-service/model" "github.com/SigNoz/signoz/ee/query-service/model"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model" basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types"
ossTypes "github.com/SigNoz/signoz/pkg/types"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -164,7 +163,7 @@ func (m *modelDao) CreateDomain(ctx context.Context, domain *types.GettableOrgDo
Name: domain.Name, Name: domain.Name,
OrgID: domain.OrgID, OrgID: domain.OrgID,
Data: string(configJson), 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(). _, err = m.sqlStore.BunDB().NewInsert().
@ -198,7 +197,7 @@ func (m *modelDao) UpdateDomain(ctx context.Context, domain *types.GettableOrgDo
Name: domain.Name, Name: domain.Name,
OrgID: domain.OrgID, OrgID: domain.OrgID,
Data: string(configJson), Data: string(configJson),
TimeAuditable: ossTypes.TimeAuditable{UpdatedAt: time.Now()}, TimeAuditable: types.TimeAuditable{UpdatedAt: time.Now()},
} }
_, err = m.sqlStore.BunDB().NewUpdate(). _, err = m.sqlStore.BunDB().NewUpdate().

View File

@ -1,18 +1,14 @@
package sqlite package sqlite
import ( import (
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/sqlstore"
) )
type modelDao struct { type modelDao struct {
userModule user.Module sqlStore sqlstore.SQLStore
sqlStore sqlstore.SQLStore
} }
// InitDB creates and extends base model DB repository // InitDB creates and extends base model DB repository
func NewModelDao(sqlStore sqlstore.SQLStore) *modelDao { func NewModelDao(sqlStore sqlstore.SQLStore) *modelDao {
userModule := impluser.NewModule(impluser.NewStore(sqlStore)) return &modelDao{sqlStore: sqlStore}
return &modelDao{userModule: userModule, sqlStore: sqlStore}
} }

View File

@ -14,6 +14,8 @@ import (
"github.com/SigNoz/signoz/pkg/config" "github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider" "github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/config/fileprovider" "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"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants" baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/signoz"
@ -112,26 +114,6 @@ func main() {
zap.L().Fatal("Failed to add postgressqlstore factory", zap.Error(err)) 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") jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET")
if len(jwtSecret) == 0 { if len(jwtSecret) == 0 {
@ -142,6 +124,27 @@ func main() {
jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour) 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{ serverOptions := &app.ServerOptions{
Config: config, Config: config,
SigNoz: signoz, SigNoz: signoz,

81
pkg/emailing/config.go Normal file
View File

@ -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"
}

12
pkg/emailing/emailing.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -1,35 +1,38 @@
package impluser package impluser
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"os"
"slices" "slices"
"text/template"
"time" "time"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/telemetry" "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"
"github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/valuer" "github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap"
) )
type Module struct { type Module struct {
store types.UserStore store types.UserStore
JWT *authtypes.JWT jwt *authtypes.JWT
emailing emailing.Emailing
settings factory.ScopedProviderSettings
} }
// This module is a WIP, don't take inspiration from this. // This module is a WIP, don't take inspiration from this.
func NewModule(store types.UserStore) user.Module { func NewModule(store types.UserStore, jwt *authtypes.JWT, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module {
jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET") settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour) return &Module{
return &Module{store: store, JWT: jwt} store: store,
jwt: jwt,
emailing: emailing,
settings: settings,
}
} }
// CreateBulk implements invite.Module. // 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, "invited user email": invites[i].Email,
}, creator.Email, true, false) }, creator.Email, true, false)
// send email if SMTP is enabled if err := m.emailing.SendHTML(ctx, invites[i].Email, "You are invited to join a team in SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
if os.Getenv("SMTP_ENABLED") == "true" && bulkInvites.Invites[i].FrontendBaseUrl != "" { "CustomerName": invites[i].Name,
m.inviteEmail(&bulkInvites.Invites[i], creator.Email, creator.DisplayName, invites[i].Token) "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 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) { func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
return m.store.ListInvite(ctx, orgID) 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) { func (m *Module) GetAuthenticatedUser(ctx context.Context, orgID, email, password, refreshToken string) (*types.User, error) {
if refreshToken != "" { if refreshToken != "" {
// parse the refresh token // parse the refresh token
claims, err := m.JWT.Claims(refreshToken) claims, err := m.jwt.Claims(refreshToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -361,12 +337,12 @@ func (m *Module) GetJWTForUser(ctx context.Context, user *types.User) (types.Get
return types.GettableUserJwt{}, err 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 { if err != nil {
return types.GettableUserJwt{}, err 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 { if err != nil {
return types.GettableUserJwt{}, err return types.GettableUserJwt{}, err
} }

View File

@ -4,6 +4,9 @@ import (
"context" "context"
"testing" "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"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user"
@ -22,7 +25,9 @@ func TestRegenerateConnectionUrlWithUpdatedConfig(t *testing.T) {
require.NoError(err) require.NoError(err)
organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) 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) user, apiErr := createTestUser(organizationModule, userModule)
require.Nil(apiErr) require.Nil(apiErr)
@ -70,7 +75,9 @@ func TestAgentCheckIns(t *testing.T) {
controller, err := NewController(sqlStore) controller, err := NewController(sqlStore)
require.NoError(err) require.NoError(err)
organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) 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) user, apiErr := createTestUser(organizationModule, userModule)
require.Nil(apiErr) require.Nil(apiErr)
@ -158,7 +165,9 @@ func TestCantDisconnectNonExistentAccount(t *testing.T) {
require.NoError(err) require.NoError(err)
organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) 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) user, apiErr := createTestUser(organizationModule, userModule)
require.Nil(apiErr) require.Nil(apiErr)
@ -178,7 +187,9 @@ func TestConfigureService(t *testing.T) {
require.NoError(err) require.NoError(err)
organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) 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) user, apiErr := createTestUser(organizationModule, userModule)
require.Nil(apiErr) require.Nil(apiErr)

View File

@ -4,6 +4,9 @@ import (
"context" "context"
"testing" "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/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/modules/user/impluser"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@ -17,7 +20,9 @@ func TestIntegrationLifecycle(t *testing.T) {
ctx := context.Background() ctx := context.Background()
organizationModule := implorganization.NewModule(implorganization.NewStore(store)) 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) user, apiErr := createTestUser(organizationModule, userModule)
if apiErr != nil { if apiErr != nil {
t.Fatalf("could not create test user: %v", apiErr) t.Fatalf("could not create test user: %v", apiErr)

View File

@ -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 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 MetricsExplorerClickhouseThreads = GetOrDefaultEnvInt("METRICS_EXPLORER_CLICKHOUSE_THREADS", 8)
var UpdatedMetricsMetadataCachePrefix = GetOrDefaultEnv("METRICS_UPDATED_METADATA_CACHE_KEY", "UPDATED_METRICS_METADATA") var UpdatedMetricsMetadataCachePrefix = GetOrDefaultEnv("METRICS_UPDATED_METADATA_CACHE_KEY", "UPDATED_METRICS_METADATA")

View File

@ -9,6 +9,8 @@ import (
"github.com/SigNoz/signoz/pkg/config" "github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider" "github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/config/fileprovider" "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"
"github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/app"
@ -102,26 +104,6 @@ func main() {
version.Info.PrettyPrint(config.Version) 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 // Read the jwt secret key
jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET") jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET")
@ -133,6 +115,27 @@ func main() {
jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour) 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{ serverOptions := &app.ServerOptions{
Config: config, Config: config,
HTTPHostPort: constants.HTTPHostPort, HTTPHostPort: constants.HTTPHostPort,

View File

@ -1,6 +1,7 @@
package tests package tests
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -8,9 +9,13 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/emailing/noopemailing"
"github.com/SigNoz/signoz/pkg/modules/quickfilter" "github.com/SigNoz/signoz/pkg/modules/quickfilter"
quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" 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/http/middleware"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@ -303,7 +308,10 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed {
reader, mockClickhouse := NewMockClickhouseReader(t, testDB) reader, mockClickhouse := NewMockClickhouseReader(t, testDB)
mockClickhouse.MatchExpectationsInOrder(false) 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) userHandler := impluser.NewHandler(userModule)
modules := signoz.NewModules(testDB, userModule) modules := signoz.NewModules(testDB, userModule)
quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB)))

View File

@ -1,6 +1,7 @@
package tests package tests
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -8,7 +9,11 @@ import (
"runtime/debug" "runtime/debug"
"strings" "strings"
"testing" "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/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter" "github.com/SigNoz/signoz/pkg/modules/quickfilter"
quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" 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/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes" "github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/mux" "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) 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) userHandler := impluser.NewHandler(userModule)
modules := signoz.NewModules(sqlStore, userModule) modules := signoz.NewModules(sqlStore, userModule)
handlers := signoz.NewHandlers(modules, userHandler) handlers := signoz.NewHandlers(modules, userHandler)

View File

@ -1,6 +1,7 @@
package tests package tests
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -8,8 +9,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/emailing/noopemailing"
"github.com/SigNoz/signoz/pkg/modules/quickfilter" "github.com/SigNoz/signoz/pkg/modules/quickfilter"
quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" 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/http/middleware"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "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) reader, mockClickhouse := NewMockClickhouseReader(t, testDB)
mockClickhouse.MatchExpectationsInOrder(false) 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) userHandler := impluser.NewHandler(userModule)
modules := signoz.NewModules(testDB, userModule) modules := signoz.NewModules(testDB, userModule)
handlers := signoz.NewHandlers(modules, userHandler) handlers := signoz.NewHandlers(modules, userHandler)

View File

@ -1,6 +1,7 @@
package tests package tests
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -8,6 +9,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/emailing/noopemailing"
"github.com/SigNoz/signoz/pkg/modules/quickfilter" "github.com/SigNoz/signoz/pkg/modules/quickfilter"
quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" 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/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes" "github.com/SigNoz/signoz/pkg/types/pipelinetypes"
mockhouse "github.com/srikanthccv/ClickHouse-go-mock" mockhouse "github.com/srikanthccv/ClickHouse-go-mock"
"github.com/stretchr/testify/require" "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) 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) userHandler := impluser.NewHandler(userModule)
modules := signoz.NewModules(testDB, userModule) modules := signoz.NewModules(testDB, userModule)
handlers := signoz.NewHandlers(modules, userHandler) handlers := signoz.NewHandlers(modules, userHandler)

View File

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

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"path"
"reflect" "reflect"
"time" "time"
@ -12,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/apiserver" "github.com/SigNoz/signoz/pkg/apiserver"
"github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/config" "github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/instrumentation" "github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/prometheus"
@ -57,6 +59,9 @@ type Config struct {
// Alertmanager config // Alertmanager config
Alertmanager alertmanager.Config `mapstructure:"alertmanager" yaml:"alertmanager"` 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. // 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(), telemetrystore.NewConfigFactory(),
prometheus.NewConfigFactory(), prometheus.NewConfigFactory(),
alertmanager.NewConfigFactory(), alertmanager.NewConfigFactory(),
emailing.NewConfigFactory(),
} }
conf, err := config.New(ctx, resolverConfig, configFactories) conf, err := config.New(ctx, resolverConfig, configFactories)
@ -186,4 +192,42 @@ func mergeAndEnsureBackwardCompatibility(config *Config, deprecatedFlags Depreca
if deprecatedFlags.Config != "" { 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.") 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")
}
} }

View File

@ -7,6 +7,9 @@ import (
"github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/cache/memorycache" "github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/cache/rediscache" "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/factory"
"github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheus" "github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheus"
@ -99,3 +102,10 @@ func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
signozalertmanager.NewFactory(sqlstore), signozalertmanager.NewFactory(sqlstore),
) )
} }
func NewEmailingProviderFactories() factory.NamedMap[factory.ProviderFactory[emailing.Emailing, emailing.Config]] {
return factory.MustNewNamedMap(
noopemailing.NewFactory(),
smtpemailing.NewFactory(),
)
}

View File

@ -42,4 +42,8 @@ func TestNewProviderFactories(t *testing.T) {
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)) NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual))
}) })
assert.NotPanics(t, func() {
NewEmailingProviderFactories()
})
} }

View File

@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/instrumentation" "github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user"
@ -29,6 +30,7 @@ type SigNoz struct {
Prometheus prometheus.Prometheus Prometheus prometheus.Prometheus
Alertmanager alertmanager.Alertmanager Alertmanager alertmanager.Alertmanager
Zeus zeus.Zeus Zeus zeus.Zeus
Emailing emailing.Emailing
Modules Modules Modules Modules
Handlers Handlers Handlers Handlers
} }
@ -38,12 +40,13 @@ func New(
config Config, config Config,
zeusConfig zeus.Config, zeusConfig zeus.Config,
zeusProviderFactory factory.ProviderFactory[zeus.Zeus, 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]], cacheProviderFactories factory.NamedMap[factory.ProviderFactory[cache.Cache, cache.Config]],
webProviderFactories factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]], webProviderFactories factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]],
sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]], sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]],
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]], telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
diModules func(sqlstore.SQLStore) user.Module, userModuleFactory func(sqlstore sqlstore.SQLStore, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module,
diHandlers func(user.Module) user.Handler, userHandlerFactory func(user.Module) user.Handler,
) (*SigNoz, error) { ) (*SigNoz, error) {
// Initialize instrumentation // Initialize instrumentation
instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz") instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz")
@ -68,6 +71,18 @@ func New(
return nil, err 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 // Initialize cache from the available cache provider factories
cache, err := factory.NewProviderFromNamedMap( cache, err := factory.NewProviderFromNamedMap(
ctx, ctx,
@ -156,8 +171,8 @@ func New(
return nil, err return nil, err
} }
userModule := diModules(sqlstore) userModule := userModuleFactory(sqlstore, emailing, providerSettings)
userHandler := diHandlers(userModule) userHandler := userHandlerFactory(userModule)
// Initialize all modules // Initialize all modules
modules := NewModules(sqlstore, userModule) modules := NewModules(sqlstore, userModule)
@ -184,6 +199,7 @@ func New(
Prometheus: prometheus, Prometheus: prometheus,
Alertmanager: alertmanager, Alertmanager: alertmanager,
Zeus: zeus, Zeus: zeus,
Emailing: emailing,
Modules: modules, Modules: modules,
Handlers: handlers, Handlers: handlers,
}, nil }, nil

34
pkg/smtp/client/auth.go Normal file
View File

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

View File

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

56
pkg/smtp/client/option.go Normal file
View File

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

360
pkg/smtp/client/smtp.go Normal file
View File

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

View File

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