Files
phishingclub/backend/service/smtpConfiguration.go
2025-09-15 19:31:18 +02:00

628 lines
17 KiB
Go

package service
import (
"context"
"crypto/tls"
"strings"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/build"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/log"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
"github.com/wneessen/go-mail"
"gorm.io/gorm"
)
// SMTPConfiguration is a SMTP configuration service
type SMTPConfiguration struct {
Common
SMTPConfigurationRepository *repository.SMTPConfiguration
CampaignTemplateService *CampaignTemplate
}
// Create creates a new SMTP configuration
func (s *SMTPConfiguration) Create(
ctx context.Context,
session *model.Session,
conf *model.SMTPConfiguration,
) (*uuid.UUID, error) {
ae := NewAuditEvent("SMTPConfiguration.Create", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// validate data
if err := conf.Validate(); err != nil {
s.Logger.Errorw("failed to validate SMTP configuration", "error", err)
return nil, errs.Wrap(err)
}
// check uniqueness
var companyID *uuid.UUID
if cid, err := conf.CompanyID.Get(); err == nil {
companyID = &cid
}
name := conf.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
s.SMTPConfigurationRepository.DB,
"smtp_configurations",
name.String(),
companyID,
nil,
)
if err != nil {
s.Logger.Errorw("failed to check SMTP uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
s.Logger.Debugw("smtp configuration name is already taken", "name", name.String())
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// create config
id, err := s.SMTPConfigurationRepository.Insert(
ctx,
conf,
)
if err != nil {
s.Logger.Errorw("failed to create SMTP configuration", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
s.AuditLogAuthorized(ae)
return id, nil
}
// GetAll gets SMTP configurations
func (s *SMTPConfiguration) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.SMTPConfigurationOption,
) (*model.Result[model.SMTPConfiguration], error) {
result := model.NewEmptyResult[model.SMTPConfiguration]()
ae := NewAuditEvent("SMTPConfiguration.GetAll", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get
result, err = s.SMTPConfigurationRepository.GetAll(
ctx,
companyID,
options,
)
if err != nil {
s.Logger.Errorw("failed to get SMTP configurations", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetByID gets a SMTP configuration by ID
func (s *SMTPConfiguration) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
options *repository.SMTPConfigurationOption,
) (*model.SMTPConfiguration, error) {
ae := NewAuditEvent("SMTPConfiguration.GetByID", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get config
config, err := s.SMTPConfigurationRepository.GetByID(
ctx,
id,
options,
)
if err != nil {
s.Logger.Errorw("failed to get SMTP configuration", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return config, nil
}
// SendTestEmail tests a SMTP configuration by ID
func (s *SMTPConfiguration) SendTestEmail(
g *gin.Context,
session *model.Session,
id *uuid.UUID,
to *vo.Email,
from *vo.Email,
) error {
ae := NewAuditEvent("SMTPConfiguration.SendTestEmail", session)
ae.Details["id"] = id.String()
ae.Details["to-email"] = to.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return err
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
smtpConfig, err := s.GetByID(
g,
session,
id,
&repository.SMTPConfigurationOption{
WithHeaders: true,
},
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Logger.Errorw("smtp configuration did not load", "error", err)
return err
}
smtpPort, err := smtpConfig.Port.Get()
if err != nil {
s.Logger.Errorw("failed to get smtp port", "error", err)
return err
}
smtpHost, err := smtpConfig.Host.Get()
if err != nil {
s.Logger.Errorw("failed to get smtp host", "error", err)
return err
}
smtpIgnoreCertErrors, err := smtpConfig.IgnoreCertErrors.Get()
if err != nil {
s.Logger.Errorw("failed to get smtp ignore cert errors", "error", err)
return err
}
m := mail.NewMsg(mail.WithNoDefaultUserAgent())
err = m.EnvelopeFrom(from.String())
if err != nil {
s.Logger.Errorw("failed to set envelope from", "error", err)
return err
}
// headers
err = m.From(from.String())
if err != nil {
s.Logger.Errorw("failed to set mail header 'From'", "error", err)
return err
}
err = m.To(to.String())
if err != nil {
s.Logger.Errorw("failed to set mail header 'To'", "error", err)
return err
}
if headers := smtpConfig.Headers; headers != nil {
for _, header := range headers {
key := header.Key.MustGet()
value := header.Value.MustGet()
m.SetGenHeader(
mail.Header(key.String()),
value.String(),
)
}
}
m.Subject("Configuration Test")
m.SetBodyString("text/html",
`<i>This is a test email to verify the SMTP configuration.</i>`,
)
// setup client
emailOptions := []mail.Option{
mail.WithPort(smtpPort.Int()),
mail.WithTLSConfig(
&tls.Config{
ServerName: smtpHost.String(),
// #nosec
InsecureSkipVerify: smtpIgnoreCertErrors,
// MinVersion: tls.VersionTLS12,
},
),
}
// setup authentication if provided
username, err := smtpConfig.Username.Get()
if err != nil {
s.Logger.Errorw("failed to get smtp username", "error", err)
return err
}
password, err := smtpConfig.Password.Get()
if err != nil {
s.Logger.Errorw("failed to get smtp password", "error", err)
return err
}
if un := username.String(); len(un) > 0 {
emailOptions = append(
emailOptions,
mail.WithUsername(
un,
),
)
if pw := password.String(); len(pw) > 0 {
emailOptions = append(
emailOptions,
mail.WithPassword(
pw,
),
)
}
}
// send mail
var mc *mail.Client
// Try different authentication methods based on configuration
// If username is provided, use authentication; otherwise try without auth first
if un := username.String(); len(un) > 0 {
// Try CRAM-MD5 first when credentials are provided
emailOptionsCRAM5 := append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthCramMD5))
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsCRAM5...)
mc.SetLogger(log.NewGoMailLoggerAdapter(s.Logger))
mc.SetDebugLog(true)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(g, m)
// Check if it's an authentication error and try PLAIN auth
if err != nil && (strings.Contains(err.Error(), "535 ") ||
strings.Contains(err.Error(), "534 ") ||
strings.Contains(err.Error(), "538 ") ||
strings.Contains(err.Error(), "CRAM-MD5") ||
strings.Contains(err.Error(), "authentication failed")) {
s.Logger.Debugf("CRAM-MD5 authentication failed, trying PLAIN auth", "error", err)
emailOptionsBasic := emailOptions
emailOptionsBasic = append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthPlain))
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsBasic...)
mc.SetLogger(log.NewGoMailLoggerAdapter(s.Logger))
mc.SetDebugLog(true)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(g, m)
}
} else {
// No credentials provided, try without authentication (e.g., local postfix)
mc, _ = mail.NewClient(smtpHost.String(), emailOptions...)
mc.SetLogger(log.NewGoMailLoggerAdapter(s.Logger))
mc.SetDebugLog(true)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(g, m)
// If no-auth fails and we get an auth-related error, log it appropriately
if err != nil && (strings.Contains(err.Error(), "530 ") ||
strings.Contains(err.Error(), "535 ") ||
strings.Contains(err.Error(), "authentication required") ||
strings.Contains(err.Error(), "AUTH")) {
s.Logger.Warnw("Server requires authentication but no credentials provided", "error", err)
}
}
if err != nil {
s.Logger.Errorw("failed to send test email", "error", err)
if m.HasSendError() {
s.Logger.Errorw("failed to send test email", "error", m.SendError())
return m.SendError()
}
return err
}
s.AuditLogAuthorized(ae)
return nil
}
// GetByNameAndCompanyID gets a SMTP configuration by name
func (s *SMTPConfiguration) GetByNameAndCompanyID(
ctx context.Context,
session *model.Session,
name *vo.String127,
companyID *uuid.UUID, // is nullable
options *repository.SMTPConfigurationOption,
) (*model.SMTPConfiguration, error) {
ae := NewAuditEvent("SMTPConfiguration.GetByNameAndCompanyID", session)
if name != nil {
ae.Details["name"] = name.String()
}
if companyID != nil {
ae.Details["companyId"] = companyID
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get
config, err := s.SMTPConfigurationRepository.GetByNameAndCompanyID(
ctx,
name,
companyID,
options,
)
if err != nil {
s.Logger.Errorw("failed to get SMTP configuration", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return config, nil
}
// UpdateByID updates a SMTP configuration by ID
func (s *SMTPConfiguration) UpdateByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
incoming *model.SMTPConfiguration,
) error {
ae := NewAuditEvent("SMTPConfiguration.UpdateByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return err
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get current
current, err := s.SMTPConfigurationRepository.GetByID(
ctx,
id,
&repository.SMTPConfigurationOption{},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
s.Logger.Errorw("SMTP configuration not found", "error", id)
return err
}
if err != nil {
s.Logger.Errorw("failed to update SMTP configuration", "error", err)
return err
}
// update config - if a field is present and not null, update it
if v, err := incoming.Name.Get(); err == nil {
var companyID *uuid.UUID
if cid, err := current.CompanyID.Get(); err == nil {
companyID = &cid
}
// check uniqueness
name := incoming.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
s.SMTPConfigurationRepository.DB,
"smtp_configurations",
name.String(),
companyID,
id,
)
if err != nil {
s.Logger.Errorw("failed to check SMTP uniqueness", "error", err)
return err
}
if !isOK {
s.Logger.Debugw("smtp configuration name is already taken", "name", name.String())
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
current.Name.Set(v)
}
if v, err := incoming.Host.Get(); err == nil {
current.Host.Set(v)
}
if v, err := incoming.Port.Get(); err == nil {
current.Port.Set(v)
}
if v, err := incoming.Username.Get(); err == nil {
current.Username.Set(v)
}
if v, err := incoming.Password.Get(); err == nil {
current.Password.Set(v)
}
if v, err := incoming.IgnoreCertErrors.Get(); err == nil {
current.IgnoreCertErrors.Set(v)
}
if err := incoming.Validate(); err != nil {
s.Logger.Errorw("failed to update SMTP configuration", "error", err)
return err
}
// update
err = s.SMTPConfigurationRepository.UpdateByID(
ctx,
id,
current,
)
if err != nil {
s.Logger.Errorw("failed to update SMTP configuration", "error", err)
return err
}
s.AuditLogAuthorized(ae)
return nil
}
// AddHeader adds a header to a SMTP configuration
func (s *SMTPConfiguration) AddHeader(
ctx context.Context,
session *model.Session,
smtpID *uuid.UUID,
header *model.SMTPHeader,
) (*uuid.UUID, error) {
ae := NewAuditEvent("SMTPConfiguration.AddHeader", session)
ae.Details["id"] = smtpID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// ensure config exists
_, err = s.SMTPConfigurationRepository.GetByID(
ctx,
smtpID,
&repository.SMTPConfigurationOption{},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
s.Logger.Debugw("SMTP configuration not found", "error", smtpID)
return nil, errs.Wrap(err)
}
if err != nil {
s.Logger.Errorw("failed to add header to SMTP configuration", "error", err)
return nil, errs.Wrap(err)
}
header.SmtpID.Set(*smtpID)
// validate header
if err := header.Validate(); err != nil {
s.Logger.Errorw("failed to validate SMTP header", "error", err)
return nil, errs.Wrap(err)
}
// save header to configuration
headerID, err := s.SMTPConfigurationRepository.AddHeader(
ctx,
header,
)
if err != nil {
s.Logger.Errorw("failed to add header to SMTP configuration", "error", err)
return nil, errs.Wrap(err)
}
s.AuditLogAuthorized(ae)
return headerID, nil
}
// RemoveHeader removes a header from a SMTP configuration
func (s *SMTPConfiguration) RemoveHeader(
ctx context.Context,
session *model.Session,
smtpID *uuid.UUID,
headerID *uuid.UUID,
) error {
ae := NewAuditEvent("SMTPConfiguration.RemoveHeader", session)
ae.Details["smtpId"] = smtpID.String()
ae.Details["headerId"] = headerID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return err
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get config, ensure config exists
_, err = s.SMTPConfigurationRepository.GetByID(
ctx,
smtpID,
&repository.SMTPConfigurationOption{},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
s.Logger.Debugw("SMTP configuration not found", "error", smtpID)
return err
}
if err != nil {
s.Logger.Errorw("failed to remove header from SMTP configuration", "error", err)
return err
}
// remove header
err = s.SMTPConfigurationRepository.RemoveHeader(
ctx,
headerID,
)
if err != nil {
s.Logger.Errorw("failed to remove header from SMTP configuration", "error", err)
return err
}
s.AuditLogAuthorized(ae)
return nil
}
// DeleteByID deletes a SMTP configuration by ID
// including all headers attached to it
func (s *SMTPConfiguration) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("SMTPConfiguration.DeleteByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return err
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// delete the relation from the campaign templates
err = s.CampaignTemplateService.RemoveSmtpBySmtpID(
ctx,
session,
id,
)
if err != nil {
s.Logger.Errorw("failed to remove SMTP configuration relation from campaign templates",
"error", err,
)
return err
}
// delete config
err = s.SMTPConfigurationRepository.DeleteByID(
ctx,
id,
)
if err != nil {
s.Logger.Errorw("failed to delete SMTP configuration", "error", err)
return err
}
s.AuditLogAuthorized(ae)
return nil
}