diff --git a/backend/app/administration.go b/backend/app/administration.go index 47bc945..7f09676 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -77,6 +77,10 @@ const ( ROUTE_V1_COMPANY_SCIM_TOKEN = "/api/v1/company/scim/:companyID/token" ROUTE_V1_COMPANY_SCIM_PRUNE = "/api/v1/company/scim/:companyID/prune" ROUTE_V1_COMPANY_SCIM_RESTORE = "/api/v1/company/scim/:companyID/restore" + ROUTE_V1_COMPANY_REPORT_CONFIG = "/api/v1/company/report-config/:companyID" + ROUTE_V1_COMPANY_REPORT_CONFIG_LOG = "/api/v1/company/report-config/:companyID/log" + ROUTE_V1_REPORT_CONFIG_GLOBAL = "/api/v1/report-config" + ROUTE_V1_REPORT_CONFIG_SEND = "/api/v1/report-config/send/:id" // scim v2 provisioning endpoints (public — authenticated via bearer token) ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG = "/api/v1/scim/v2/:companyID/ServiceProviderConfig" ROUTE_SCIM_V2_RESOURCE_TYPES = "/api/v1/scim/v2/:companyID/ResourceTypes" @@ -362,6 +366,16 @@ func setupRoutes( POST(ROUTE_V1_COMPANY_SCIM_TOKEN, middleware.SessionHandler, controllers.CompanyScimConfig.RotateToken). POST(ROUTE_V1_COMPANY_SCIM_PRUNE, middleware.SessionHandler, controllers.CompanyScimConfig.Prune). POST(ROUTE_V1_COMPANY_SCIM_RESTORE, middleware.SessionHandler, controllers.CompanyScimConfig.Restore). + // company report delivery + GET(ROUTE_V1_COMPANY_REPORT_CONFIG, middleware.SessionHandler, controllers.CompanyReportConfig.GetByCompanyID). + POST(ROUTE_V1_COMPANY_REPORT_CONFIG, middleware.SessionHandler, controllers.CompanyReportConfig.Upsert). + DELETE(ROUTE_V1_COMPANY_REPORT_CONFIG, middleware.SessionHandler, controllers.CompanyReportConfig.Delete). + GET(ROUTE_V1_COMPANY_REPORT_CONFIG_LOG, middleware.SessionHandler, controllers.CompanyReportConfig.GetLogByCompanyID). + // global default report delivery + GET(ROUTE_V1_REPORT_CONFIG_GLOBAL, middleware.SessionHandler, controllers.CompanyReportConfig.GetGlobal). + POST(ROUTE_V1_REPORT_CONFIG_GLOBAL, middleware.SessionHandler, controllers.CompanyReportConfig.UpsertGlobal). + DELETE(ROUTE_V1_REPORT_CONFIG_GLOBAL, middleware.SessionHandler, controllers.CompanyReportConfig.DeleteGlobal). + POST(ROUTE_V1_REPORT_CONFIG_SEND, middleware.SessionHandler, controllers.CompanyReportConfig.SendNow). // options GET(ROUTE_V1_OPTION_GET, middleware.SessionHandler, controllers.Option.Get). POST(ROUTE_V1_OPTION, middleware.SessionHandler, middleware.SessionHandler, controllers.Option.Update). diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 8b9b5dc..69aaec2 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -40,10 +40,11 @@ type Controllers struct { Backup *controller.Backup IPAllowList *controller.IPAllowList OAuthProvider *controller.OAuthProvider - CompanyScimConfig *controller.CompanyScimConfig - Scim *controller.Scim - RemoteBrowser *controller.RemoteBrowserController - ReportTemplate *controller.ReportTemplate + CompanyScimConfig *controller.CompanyScimConfig + CompanyReportConfig *controller.CompanyReportConfig + Scim *controller.Scim + RemoteBrowser *controller.RemoteBrowserController + ReportTemplate *controller.ReportTemplate } // NewControllers creates a collection of controllers @@ -205,6 +206,11 @@ func NewControllers( CompanyScimConfigService: services.CompanyScimConfig, ScimService: services.Scim, } + companyReportConfig := &controller.CompanyReportConfig{ + Common: common, + CompanyReportConfigService: services.CompanyReportConfig, + CampaignService: services.Campaign, + } scim := &controller.Scim{ Common: common, ScimService: services.Scim, @@ -260,9 +266,10 @@ func NewControllers( Backup: backup, IPAllowList: ipAllowList, OAuthProvider: oauthProvider, - CompanyScimConfig: companyScimConfig, - Scim: scim, - RemoteBrowser: remoteBrowser, - ReportTemplate: reportTemplate, + CompanyScimConfig: companyScimConfig, + CompanyReportConfig: companyReportConfig, + Scim: scim, + RemoteBrowser: remoteBrowser, + ReportTemplate: reportTemplate, } } diff --git a/backend/app/repositories.go b/backend/app/repositories.go index bf38c7c..2342f72 100644 --- a/backend/app/repositories.go +++ b/backend/app/repositories.go @@ -32,6 +32,8 @@ type Repositories struct { OAuthState *repository.OAuthState MicrosoftDeviceCode *repository.MicrosoftDeviceCode CompanyScimConfig *repository.CompanyScimConfig + CompanyReportConfig *repository.CompanyReportConfig + ReportSendLog *repository.ReportSendLog RemoteBrowser *repository.RemoteBrowser ReportTemplate *repository.ReportTemplate } @@ -67,6 +69,8 @@ func NewRepositories( OAuthState: &repository.OAuthState{DB: db}, MicrosoftDeviceCode: &repository.MicrosoftDeviceCode{DB: db}, CompanyScimConfig: &repository.CompanyScimConfig{DB: db}, + CompanyReportConfig: &repository.CompanyReportConfig{DB: db}, + ReportSendLog: &repository.ReportSendLog{DB: db}, RemoteBrowser: &repository.RemoteBrowser{DB: db}, ReportTemplate: &repository.ReportTemplate{DB: db}, } diff --git a/backend/app/services.go b/backend/app/services.go index e9c12f8..a73d39b 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -44,6 +44,7 @@ type Services struct { OAuthProvider *service.OAuthProvider MicrosoftDeviceCode *service.MicrosoftDeviceCode CompanyScimConfig *service.CompanyScimConfig + CompanyReportConfig *service.CompanyReportConfig Scim *service.Scim RemoteBrowser *service.RemoteBrowser ReportTemplate *service.ReportTemplate @@ -63,6 +64,7 @@ func NewServices( certMagicCache *certmagic.Cache, filePath string, trustedProxies []string, + remoteBrowserExecPath string, ) *Services { common := service.Common{ Logger: logger, @@ -229,6 +231,10 @@ func NewServices( AttachmentPath: attachmentPath, RemoteBrowserService: remoteBrowser, ReportTemplateRepository: repositories.ReportTemplate, + CompanyReportConfigRepository: repositories.CompanyReportConfig, + ReportSendLogRepository: repositories.ReportSendLog, + OptionService: optionService, + RemoteBrowserExecPath: remoteBrowserExecPath, TrustedProxies: trustedProxies, } // wire campaign service into microsoft device code service now that campaign is constructed @@ -313,8 +319,15 @@ func NewServices( ReportTemplateRepository: repositories.ReportTemplate, } + companyReportConfig := &service.CompanyReportConfig{ + Common: common, + CompanyReportConfigRepository: repositories.CompanyReportConfig, + ReportSendLogRepository: repositories.ReportSendLog, + } + return &Services{ CompanyScimConfig: companyScimConfig, + CompanyReportConfig: companyReportConfig, Scim: scim, Asset: asset, Attachment: attachment, diff --git a/backend/controller/companyReportConfig.go b/backend/controller/companyReportConfig.go new file mode 100644 index 0000000..f015c7f --- /dev/null +++ b/backend/controller/companyReportConfig.go @@ -0,0 +1,240 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/service" + "github.com/phishingclub/phishingclub/vo" +) + +// CompanyReportConfig is the report delivery configuration controller. handlers +// come in a per company variant (companyID from the url) and a global variant +// (nil companyID) that edits the fallback config. +type CompanyReportConfig struct { + Common + CompanyReportConfigService *service.CompanyReportConfig + CampaignService *service.Campaign +} + +// upsertReportConfigRequest is the request body for the upsert handlers +type upsertReportConfigRequest struct { + Enabled bool `json:"enabled"` + SendOnFinish bool `json:"sendOnFinish"` + RecipientGroupID *string `json:"recipientGroupID"` + SMTPConfigurationID *string `json:"smtpConfigurationID"` + SenderEmail *string `json:"senderEmail"` + EmailSubject *string `json:"emailSubject"` + EmailBody *string `json:"emailBody"` +} + +// get returns the config for the given scope (nil companyID is the global default) +func (c *CompanyReportConfig) get(g *gin.Context, companyID *uuid.UUID) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + config, err := c.CompanyReportConfigService.GetByCompanyID( + g.Request.Context(), + session, + companyID, + ) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, config) +} + +// upsert creates or updates the config for the given scope +func (c *CompanyReportConfig) upsert(g *gin.Context, companyID *uuid.UUID) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + var req upsertReportConfigRequest + if ok := c.handleParseRequest(g, &req); !ok { + return + } + incoming := &model.CompanyReportConfig{ + Enabled: req.Enabled, + SendOnFinish: req.SendOnFinish, + RecipientGroupID: nullable.NewNullNullable[uuid.UUID](), + SMTPConfigurationID: nullable.NewNullNullable[uuid.UUID](), + SenderEmail: nullable.NewNullNullable[vo.Email](), + EmailSubject: nullable.NewNullableWithValue(""), + EmailBody: nullable.NewNullableWithValue(""), + } + if req.EmailSubject != nil { + incoming.EmailSubject.Set(*req.EmailSubject) + } + if req.EmailBody != nil { + incoming.EmailBody.Set(*req.EmailBody) + } + if req.RecipientGroupID != nil && *req.RecipientGroupID != "" { + groupID, err := uuid.Parse(*req.RecipientGroupID) + if err != nil { + c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID) + return + } + incoming.RecipientGroupID.Set(groupID) + } + if req.SMTPConfigurationID != nil && *req.SMTPConfigurationID != "" { + smtpID, err := uuid.Parse(*req.SMTPConfigurationID) + if err != nil { + c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID) + return + } + incoming.SMTPConfigurationID.Set(smtpID) + } + if req.SenderEmail != nil && *req.SenderEmail != "" { + email, err := vo.NewEmail(*req.SenderEmail) + if err != nil { + c.Response.BadRequestMessage(g, "invalid sender email") + return + } + incoming.SenderEmail.Set(*email) + } + result, err := c.CompanyReportConfigService.Upsert( + g.Request.Context(), + session, + companyID, + incoming, + ) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, result) +} + +// remove deletes the config for the given scope +func (c *CompanyReportConfig) remove(g *gin.Context, companyID *uuid.UUID) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + err := c.CompanyReportConfigService.DeleteByCompanyID( + g.Request.Context(), + session, + companyID, + ) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + +// parseCompanyIDParam parses the :companyID url param +func (c *CompanyReportConfig) parseCompanyIDParam(g *gin.Context) (*uuid.UUID, bool) { + companyID, err := uuid.Parse(g.Param("companyID")) + if err != nil { + c.Logger.Debugw("failed to parse companyID param", "error", err) + c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID) + return nil, false + } + return &companyID, true +} + +// GetByCompanyID returns the report delivery configuration for the given company +func (c *CompanyReportConfig) GetByCompanyID(g *gin.Context) { + companyID, ok := c.parseCompanyIDParam(g) + if !ok { + return + } + c.get(g, companyID) +} + +// Upsert creates or updates the report delivery configuration for the given company +func (c *CompanyReportConfig) Upsert(g *gin.Context) { + companyID, ok := c.parseCompanyIDParam(g) + if !ok { + return + } + c.upsert(g, companyID) +} + +// Delete removes the report delivery configuration for the given company +func (c *CompanyReportConfig) Delete(g *gin.Context) { + companyID, ok := c.parseCompanyIDParam(g) + if !ok { + return + } + c.remove(g, companyID) +} + +// GetGlobal returns the global default report delivery configuration +func (c *CompanyReportConfig) GetGlobal(g *gin.Context) { + c.get(g, nil) +} + +// UpsertGlobal creates or updates the global default report delivery configuration +func (c *CompanyReportConfig) UpsertGlobal(g *gin.Context) { + c.upsert(g, nil) +} + +// DeleteGlobal removes the global default report delivery configuration +func (c *CompanyReportConfig) DeleteGlobal(g *gin.Context) { + c.remove(g, nil) +} + +// GetLogByCompanyID returns the report delivery log for the given company +func (c *CompanyReportConfig) GetLogByCompanyID(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + companyID, ok := c.parseCompanyIDParam(g) + if !ok { + return + } + queryArgs, ok := c.handleQueryArgs(g) + if !ok { + return + } + // map the table column labels to the underlying db columns + queryArgs.RemapOrderBy(map[string]string{ + "date": "created_at", + "created_at": "created_at", + "campaign": "campaign_name", + "group": "group_name", + "trigger": "trigger", + "status": "status", + "recipients": "recipient_count", + }) + queryArgs.DefaultSortByCreatedAt() + result, err := c.CompanyReportConfigService.ListLogByCompanyID( + g.Request.Context(), + session, + companyID, + &repository.ReportSendLogOption{QueryArgs: queryArgs}, + ) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, result) +} + +// SendNow generates the report for the campaign and emails it to the configured +// recipient group right away. +func (c *CompanyReportConfig) SendNow(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + campaignID, ok := c.handleParseIDParam(g) + if !ok { + return + } + err := c.CampaignService.SendCampaignReport( + g.Request.Context(), + session, + campaignID, + true, + ) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} diff --git a/backend/database/companyReportConfig.go b/backend/database/companyReportConfig.go new file mode 100644 index 0000000..d10556a --- /dev/null +++ b/backend/database/companyReportConfig.go @@ -0,0 +1,45 @@ +package database + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + COMPANY_REPORT_CONFIG_TABLE = "company_report_configs" +) + +// CompanyReportConfig holds the automatic report delivery configuration. +// a row with a NULL company_id is the global default used as a fallback when a +// company has no config of its own. when enabled, a campaign report PDF can be +// emailed to a recipient group, either on demand or when a campaign is closed. +type CompanyReportConfig struct { + ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"` + CreatedAt *time.Time `gorm:"not null;index;"` + UpdatedAt *time.Time `gorm:"not null;index;"` + CompanyID *uuid.UUID `gorm:"uniqueIndex;type:uuid"` // NULL is the global default + Enabled bool `gorm:"not null;default:false"` + SendOnFinish bool `gorm:"not null;default:false"` // auto send when a campaign is closed + RecipientGroupID *uuid.UUID `gorm:"type:uuid"` // group that receives the report + SMTPConfigurationID *uuid.UUID `gorm:"type:uuid"` // smtp used to send the report + SenderEmail string `gorm:"not null;default:''"` // from address used for the report email + EmailSubject string `gorm:"not null;default:'';type:text"` // subject of the delivery email + EmailBody string `gorm:"not null;default:'';type:text"` // html body of the delivery email + LastSentAt *time.Time // nullable: last time a report was successfully delivered + + Company *Company `gorm:"foreignKey:CompanyID"` + RecipientGroup *RecipientGroup `gorm:"foreignKey:RecipientGroupID"` + SMTPConfiguration *SMTPConfiguration `gorm:"foreignKey:SMTPConfigurationID"` +} + +func (e *CompanyReportConfig) Migrate(db *gorm.DB) error { + // enforce at most one global config (company_id IS NULL) + idx := `CREATE UNIQUE INDEX IF NOT EXISTS idx_company_report_configs_null_company_id ON company_report_configs ((company_id IS NULL)) WHERE (company_id IS NULL)` + return db.Exec(idx).Error +} + +func (CompanyReportConfig) TableName() string { + return COMPANY_REPORT_CONFIG_TABLE +} diff --git a/backend/database/reportSendLog.go b/backend/database/reportSendLog.go new file mode 100644 index 0000000..523702b --- /dev/null +++ b/backend/database/reportSendLog.go @@ -0,0 +1,40 @@ +package database + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + REPORT_SEND_LOG_TABLE = "report_send_logs" +) + +// ReportSendLog is one record of a report delivery attempt for a campaign. +// values are denormalized so the row stays meaningful after the campaign, +// group or smtp config is changed or removed. +type ReportSendLog struct { + ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"` + CreatedAt *time.Time `gorm:"not null;index;"` + CompanyID *uuid.UUID `gorm:"type:uuid;index"` // the campaign's company, NULL for a global campaign + CampaignID *uuid.UUID `gorm:"type:uuid;index"` + CampaignName string `gorm:"not null;default:''"` + GroupName string `gorm:"not null;default:''"` // recipient group name at send time + Trigger string `gorm:"not null;default:''"` // on_demand or on_finish + Status string `gorm:"not null;default:''"` // sent or failed + RecipientCount int `gorm:"not null;default:0"` + Recipients string `gorm:"not null;default:'';type:text"` // comma separated recipient addresses + SenderEmail string `gorm:"not null;default:''"` + ErrorMessage string `gorm:"not null;default:'';type:text"` // empty on success + + Company *Company `gorm:"foreignKey:CompanyID"` +} + +func (e *ReportSendLog) Migrate(db *gorm.DB) error { + return nil +} + +func (ReportSendLog) TableName() string { + return REPORT_SEND_LOG_TABLE +} diff --git a/backend/main.go b/backend/main.go index 399fda8..080c104 100644 --- a/backend/main.go +++ b/backend/main.go @@ -233,6 +233,7 @@ func main() { certMagicCache, *flagFilePath, conf.IPSecurity.TrustedProxies, + conf.RemoteBrowser.ExecPath, ) // get entra-id options and setup msal client ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background()) diff --git a/backend/model/companyReportConfig.go b/backend/model/companyReportConfig.go new file mode 100644 index 0000000..218059c --- /dev/null +++ b/backend/model/companyReportConfig.go @@ -0,0 +1,78 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/validate" + "github.com/phishingclub/phishingclub/vo" +) + +// CompanyReportConfig is the automatic report delivery configuration for a company +type CompanyReportConfig struct { + ID nullable.Nullable[uuid.UUID] `json:"id"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` + CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` + Enabled bool `json:"enabled"` + SendOnFinish bool `json:"sendOnFinish"` + RecipientGroupID nullable.Nullable[uuid.UUID] `json:"recipientGroupID"` + SMTPConfigurationID nullable.Nullable[uuid.UUID] `json:"smtpConfigurationID"` + SenderEmail nullable.Nullable[vo.Email] `json:"senderEmail"` + EmailSubject nullable.Nullable[string] `json:"emailSubject"` + EmailBody nullable.Nullable[string] `json:"emailBody"` + LastSentAt *time.Time `json:"lastSentAt"` +} + +// Validate checks if the config is in a valid state. +// when enabled the delivery target fields are required, otherwise the config may +// be saved partially while the user is still setting it up. +func (c *CompanyReportConfig) Validate() error { + if !c.Enabled { + return nil + } + if err := validate.NullableFieldRequired("recipientGroupID", c.RecipientGroupID); err != nil { + return err + } + if err := validate.NullableFieldRequired("smtpConfigurationID", c.SMTPConfigurationID); err != nil { + return err + } + if err := validate.NullableFieldRequired("senderEmail", c.SenderEmail); err != nil { + return err + } + return nil +} + +// ToDBMap converts updatable fields to a map for persistence +func (c *CompanyReportConfig) ToDBMap() map[string]any { + m := map[string]any{} + m["enabled"] = c.Enabled + m["send_on_finish"] = c.SendOnFinish + + m["recipient_group_id"] = nil + if c.RecipientGroupID.IsSpecified() && !c.RecipientGroupID.IsNull() { + m["recipient_group_id"] = c.RecipientGroupID.MustGet() + } + + m["smtp_configuration_id"] = nil + if c.SMTPConfigurationID.IsSpecified() && !c.SMTPConfigurationID.IsNull() { + m["smtp_configuration_id"] = c.SMTPConfigurationID.MustGet() + } + + m["sender_email"] = "" + if c.SenderEmail.IsSpecified() && !c.SenderEmail.IsNull() { + m["sender_email"] = c.SenderEmail.MustGet().String() + } + + m["email_subject"] = "" + if c.EmailSubject.IsSpecified() && !c.EmailSubject.IsNull() { + m["email_subject"] = c.EmailSubject.MustGet() + } + + m["email_body"] = "" + if c.EmailBody.IsSpecified() && !c.EmailBody.IsNull() { + m["email_body"] = c.EmailBody.MustGet() + } + return m +} diff --git a/backend/model/reportSendLog.go b/backend/model/reportSendLog.go new file mode 100644 index 0000000..761a6f0 --- /dev/null +++ b/backend/model/reportSendLog.go @@ -0,0 +1,47 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" +) + +// ReportSendLog is one record of a report delivery attempt +type ReportSendLog struct { + ID nullable.Nullable[uuid.UUID] `json:"id"` + CreatedAt *time.Time `json:"createdAt"` + CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` + CampaignID nullable.Nullable[uuid.UUID] `json:"campaignID"` + CampaignName string `json:"campaignName"` + GroupName string `json:"groupName"` + Trigger string `json:"trigger"` + Status string `json:"status"` + RecipientCount int `json:"recipientCount"` + Recipients string `json:"recipients"` + SenderEmail string `json:"senderEmail"` + ErrorMessage string `json:"errorMessage"` +} + +// ToDBMap converts the fields for persistence +func (r *ReportSendLog) ToDBMap() map[string]any { + m := map[string]any{} + m["campaign_name"] = r.CampaignName + m["group_name"] = r.GroupName + m["trigger"] = r.Trigger + m["status"] = r.Status + m["recipient_count"] = r.RecipientCount + m["recipients"] = r.Recipients + m["sender_email"] = r.SenderEmail + m["error_message"] = r.ErrorMessage + + m["company_id"] = nil + if r.CompanyID.IsSpecified() && !r.CompanyID.IsNull() { + m["company_id"] = r.CompanyID.MustGet() + } + m["campaign_id"] = nil + if r.CampaignID.IsSpecified() && !r.CampaignID.IsNull() { + m["campaign_id"] = r.CampaignID.MustGet() + } + return m +} diff --git a/backend/repository/companyReportConfig.go b/backend/repository/companyReportConfig.go new file mode 100644 index 0000000..ee13b98 --- /dev/null +++ b/backend/repository/companyReportConfig.go @@ -0,0 +1,220 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/vo" + "gorm.io/gorm" +) + +// CompanyReportConfig is the repository for company report delivery configuration +type CompanyReportConfig struct { + DB *gorm.DB +} + +// Insert inserts a new company report config row +func (r *CompanyReportConfig) Insert( + ctx context.Context, + config *model.CompanyReportConfig, +) (*uuid.UUID, error) { + id := uuid.New() + row := config.ToDBMap() + row["id"] = id + AddTimestamps(row) + + // a NULL company_id is the global default config + row["company_id"] = nil + if companyID, err := config.CompanyID.Get(); err == nil { + row["company_id"] = companyID.String() + } + + res := r.DB. + Model(&database.CompanyReportConfig{}). + Create(row) + + if res.Error != nil { + return nil, errs.Wrap(res.Error) + } + return &id, nil +} + +// GetByCompanyID fetches the report config for a given company, or the global +// default config when companyID is nil. +func (r *CompanyReportConfig) GetByCompanyID( + ctx context.Context, + companyID *uuid.UUID, +) (*model.CompanyReportConfig, error) { + var row database.CompanyReportConfig + db := r.DB + if companyID == nil { + db = whereCompanyIsNull(db, database.COMPANY_REPORT_CONFIG_TABLE) + } else { + db = db.Where( + fmt.Sprintf( + "%s = ?", + TableColumn(database.COMPANY_REPORT_CONFIG_TABLE, "company_id"), + ), + companyID.String(), + ) + } + res := db.First(&row) + + if res.Error != nil { + return nil, errs.Wrap(res.Error) + } + return ToCompanyReportConfig(&row), nil +} + +// GetByID fetches the report config by its primary key +func (r *CompanyReportConfig) GetByID( + ctx context.Context, + id *uuid.UUID, +) (*model.CompanyReportConfig, error) { + var row database.CompanyReportConfig + res := r.DB. + Where( + fmt.Sprintf( + "%s = ?", + TableColumnID(database.COMPANY_REPORT_CONFIG_TABLE), + ), + id.String(), + ). + First(&row) + + if res.Error != nil { + return nil, errs.Wrap(res.Error) + } + return ToCompanyReportConfig(&row), nil +} + +// UpdateByID performs a partial update on the report config via ToDBMap +func (r *CompanyReportConfig) UpdateByID( + ctx context.Context, + id *uuid.UUID, + config *model.CompanyReportConfig, +) error { + row := config.ToDBMap() + AddUpdatedAt(row) + + res := r.DB. + Model(&database.CompanyReportConfig{}). + Where( + fmt.Sprintf( + "%s = ?", + TableColumnID(database.COMPANY_REPORT_CONFIG_TABLE), + ), + id.String(), + ). + Updates(row) + + if res.Error != nil { + return errs.Wrap(res.Error) + } + return nil +} + +// UpdateLastSentAt sets last_sent_at to the current UTC time for the given config ID +func (r *CompanyReportConfig) UpdateLastSentAt( + ctx context.Context, + id *uuid.UUID, +) error { + now := time.Now().UTC() + row := map[string]any{ + "last_sent_at": now, + } + AddUpdatedAt(row) + + res := r.DB. + Model(&database.CompanyReportConfig{}). + Where( + fmt.Sprintf( + "%s = ?", + TableColumnID(database.COMPANY_REPORT_CONFIG_TABLE), + ), + id.String(), + ). + Updates(row) + + if res.Error != nil { + return errs.Wrap(res.Error) + } + return nil +} + +// DeleteByCompanyID removes the report config for a given company, or the global +// default config when companyID is nil. +func (r *CompanyReportConfig) DeleteByCompanyID( + ctx context.Context, + companyID *uuid.UUID, +) error { + db := r.DB + if companyID == nil { + db = whereCompanyIsNull(db, database.COMPANY_REPORT_CONFIG_TABLE) + } else { + db = db.Where( + fmt.Sprintf( + "%s = ?", + TableColumn(database.COMPANY_REPORT_CONFIG_TABLE, "company_id"), + ), + companyID.String(), + ) + } + res := db.Delete(&database.CompanyReportConfig{}) + + if res.Error != nil { + return errs.Wrap(res.Error) + } + return nil +} + +// ToCompanyReportConfig maps a database row to the business model +func ToCompanyReportConfig(row *database.CompanyReportConfig) *model.CompanyReportConfig { + id := nullable.NewNullableWithValue(*row.ID) + + companyID := nullable.NewNullNullable[uuid.UUID]() + if row.CompanyID != nil { + companyID.Set(*row.CompanyID) + } + + recipientGroupID := nullable.NewNullNullable[uuid.UUID]() + if row.RecipientGroupID != nil { + recipientGroupID.Set(*row.RecipientGroupID) + } + + smtpConfigurationID := nullable.NewNullNullable[uuid.UUID]() + if row.SMTPConfigurationID != nil { + smtpConfigurationID.Set(*row.SMTPConfigurationID) + } + + senderEmail := nullable.NewNullNullable[vo.Email]() + if row.SenderEmail != "" { + if email, err := vo.NewEmail(row.SenderEmail); err == nil { + senderEmail.Set(*email) + } + } + + emailSubject := nullable.NewNullableWithValue(row.EmailSubject) + emailBody := nullable.NewNullableWithValue(row.EmailBody) + + return &model.CompanyReportConfig{ + ID: id, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + CompanyID: companyID, + Enabled: row.Enabled, + SendOnFinish: row.SendOnFinish, + RecipientGroupID: recipientGroupID, + SMTPConfigurationID: smtpConfigurationID, + SenderEmail: senderEmail, + EmailSubject: emailSubject, + EmailBody: emailBody, + LastSentAt: row.LastSentAt, + } +} diff --git a/backend/repository/reportSendLog.go b/backend/repository/reportSendLog.go new file mode 100644 index 0000000..d0a24eb --- /dev/null +++ b/backend/repository/reportSendLog.go @@ -0,0 +1,110 @@ +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/utils" + "github.com/phishingclub/phishingclub/vo" + "gorm.io/gorm" +) + +var reportSendLogAllowedColumns = assignTableToColumns(database.REPORT_SEND_LOG_TABLE, []string{ + "created_at", + "status", + "trigger", + "campaign_name", + "group_name", + "recipient_count", +}) + +// ReportSendLogOption is for query options +type ReportSendLogOption struct { + *vo.QueryArgs +} + +// ReportSendLog is the repository for report delivery logs +type ReportSendLog struct { + DB *gorm.DB +} + +// Insert inserts a report send log row +func (r *ReportSendLog) Insert( + ctx context.Context, + log *model.ReportSendLog, +) (*uuid.UUID, error) { + id := uuid.New() + row := log.ToDBMap() + row["id"] = id + // the log is append only and has no updated_at column + row["created_at"] = utils.NowRFC3339UTC() + + res := r.DB. + Model(&database.ReportSendLog{}). + Create(row) + + if res.Error != nil { + return nil, errs.Wrap(res.Error) + } + return &id, nil +} + +// GetAllByCompanyID returns the report send logs for a company using pagination +func (r *ReportSendLog) GetAllByCompanyID( + ctx context.Context, + companyID *uuid.UUID, + options *ReportSendLogOption, +) (*model.Result[model.ReportSendLog], error) { + result := model.NewEmptyResult[model.ReportSendLog]() + var rows []database.ReportSendLog + db := whereCompany(r.DB, database.REPORT_SEND_LOG_TABLE, companyID) + db, err := useQuery(db, database.REPORT_SEND_LOG_TABLE, options.QueryArgs, reportSendLogAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + if res := db.Find(&rows); res.Error != nil { + return result, errs.Wrap(res.Error) + } + hasNextPage, err := useHasNextPage(db, database.REPORT_SEND_LOG_TABLE, options.QueryArgs, reportSendLogAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + result.HasNextPage = hasNextPage + for _, row := range rows { + result.Rows = append(result.Rows, ToReportSendLog(&row)) + } + return result, nil +} + +// ToReportSendLog maps a database row to the business model +func ToReportSendLog(row *database.ReportSendLog) *model.ReportSendLog { + id := nullable.NewNullableWithValue(*row.ID) + + companyID := nullable.NewNullNullable[uuid.UUID]() + if row.CompanyID != nil { + companyID.Set(*row.CompanyID) + } + campaignID := nullable.NewNullNullable[uuid.UUID]() + if row.CampaignID != nil { + campaignID.Set(*row.CampaignID) + } + + return &model.ReportSendLog{ + ID: id, + CreatedAt: row.CreatedAt, + CompanyID: companyID, + CampaignID: campaignID, + CampaignName: row.CampaignName, + GroupName: row.GroupName, + Trigger: row.Trigger, + Status: row.Status, + RecipientCount: row.RecipientCount, + Recipients: row.Recipients, + SenderEmail: row.SenderEmail, + ErrorMessage: row.ErrorMessage, + } +} diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index 61c43ea..7fae3a6 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -59,6 +59,8 @@ func initialInstallAndSeed( &database.OAuthState{}, &database.MicrosoftDeviceCode{}, &database.CompanyScimConfig{}, + &database.CompanyReportConfig{}, + &database.ReportSendLog{}, &database.RemoteBrowser{}, &database.ReportTemplate{}, } diff --git a/backend/service/campaign.go b/backend/service/campaign.go index e490bee..672be82 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -30,6 +30,7 @@ import ( "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/log" "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/remotebrowser" "github.com/phishingclub/phishingclub/repository" "github.com/phishingclub/phishingclub/utils" "github.com/phishingclub/phishingclub/validate" @@ -59,6 +60,10 @@ type Campaign struct { AttachmentPath string RemoteBrowserService *RemoteBrowser ReportTemplateRepository *repository.ReportTemplate + CompanyReportConfigRepository *repository.CompanyReportConfig + ReportSendLogRepository *repository.ReportSendLog + OptionService *Option + RemoteBrowserExecPath string TrustedProxies []string } @@ -3299,6 +3304,27 @@ func (c *Campaign) closeCampaign( } } + // automatic report delivery when the company has opted in (skip test campaigns). + // rendering the PDF and sending mail can take seconds, so it runs in the + // background with its own context to avoid blocking the close path and the + // sender loop. failures are logged and a panic is recovered so it can never + // crash the process. + if !campaign.IsTest.MustGet() { + reportCampaignID := *id + go func() { + defer func() { + if r := recover(); r != nil { + c.Logger.Errorw("recovered from panic during automatic report delivery", "panic", r, "campaignID", reportCampaignID.String()) + } + }() + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := c.SendCampaignReport(bgCtx, session, &reportCampaignID, false); err != nil { + c.Logger.Errorw("failed to send automatic campaign report", "error", err, "campaignID", reportCampaignID.String()) + } + }() + } + return nil } @@ -5222,27 +5248,39 @@ func (c *Campaign) BuildReportHTML( session *model.Session, campaignID *uuid.UUID, ) (htmlContent string, campaignName string, err error) { + html, _, name, err := c.buildReportHTMLWithData(ctx, session, campaignID) + return html, name, err +} + +// buildReportHTMLWithData renders the report HTML and also returns the report data +// context, so callers like the delivery email can render with the same variables +// and template functions the report template uses. +func (c *Campaign) buildReportHTMLWithData( + ctx context.Context, + session *model.Session, + campaignID *uuid.UUID, +) (htmlContent string, reportData *model.ReportData, campaignName string, err error) { ae := NewAuditEvent("Campaign.BuildReportHTML", session) ae.Details["campaignId"] = campaignID.String() isAuthorized, authErr := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if authErr != nil && !errors.Is(authErr, errs.ErrAuthorizationFailed) { c.LogAuthError(authErr) - return "", "", errs.Wrap(authErr) + return "", nil, "", errs.Wrap(authErr) } if !isAuthorized { c.AuditLogNotAuthorized(ae) - return "", "", errs.ErrAuthorizationFailed + return "", nil, "", errs.ErrAuthorizationFailed } campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{WithCompany: true}) if err != nil { - return "", "", errs.Wrap(err) + return "", nil, "", errs.Wrap(err) } stats, err := c.CampaignRepository.GetResultStats(ctx, campaignID) if err != nil { c.Logger.Errorw("failed to get campaign result stats for report", "error", err) - return "", "", errs.Wrap(err) + return "", nil, "", errs.Wrap(err) } var companyID *uuid.UUID @@ -5253,11 +5291,11 @@ func (c *Campaign) BuildReportHTML( reportTmpl, tmplErr := c.ReportTemplateRepository.GetForCampaign(ctx, companyID) if tmplErr != nil { c.Logger.Errorw("failed to fetch report template", "error", tmplErr) - return "", "", errs.Wrap(tmplErr) + return "", nil, "", errs.Wrap(tmplErr) } templateContentVO, cErr := reportTmpl.Content.Get() if cErr != nil { - return "", "", errs.Wrap(cErr) + return "", nil, "", errs.Wrap(cErr) } templateContent := templateContentVO.String() @@ -5284,20 +5322,353 @@ func (c *Campaign) BuildReportHTML( } } - data := buildReportData(name, companyName, campaign, stats, recipients) + rd := buildReportData(name, companyName, campaign, stats, recipients) var buf bytes.Buffer tmpl, err := template.New("report").Funcs(TemplateFuncs()).Parse(templateContent) if err != nil { c.Logger.Errorw("failed to parse report template", "error", err) - return "", "", errs.Wrap(err) + return "", nil, "", errs.Wrap(err) } - if err := tmpl.Execute(&buf, data); err != nil { + if err := tmpl.Execute(&buf, rd); err != nil { c.Logger.Errorw("failed to execute report template", "error", err) - return "", "", errs.Wrap(err) + return "", nil, "", errs.Wrap(err) } // no audit on read - return buf.String(), name, nil + return buf.String(), rd, name, nil +} + +const defaultReportEmailSubject = "Campaign report: {{.CampaignName}}" +const defaultReportEmailBody = "
The phishing simulation report for {{.CampaignName}} is attached.
" + +// renderReportEmailField renders a report email subject or body template with the +// given data. on a parse or execute error it logs and falls back to the default +// template so delivery is never blocked by a malformed custom template. +func renderReportEmailField(c *Campaign, tmplStr string, fallback string, data any) string { + render := func(s string) (string, error) { + t, err := template.New("reportEmail").Funcs(TemplateFuncs()).Parse(s) + if err != nil { + return "", err + } + var b bytes.Buffer + if err := t.Execute(&b, data); err != nil { + return "", err + } + return b.String(), nil + } + out, err := render(tmplStr) + if err != nil { + c.Logger.Warnw("failed to render report email template, using default", "error", err) + if out, err = render(fallback); err != nil { + return "" + } + } + return out +} + +// sanitizeReportFilename removes characters that are unsafe in filenames +func sanitizeReportFilename(name string) string { + replacer := strings.NewReplacer( + "/", "-", "\\", "-", ":", "-", "*", "-", + "?", "-", "\"", "-", "<", "-", ">", "-", "|", "-", + ) + safe := strings.TrimSpace(replacer.Replace(name)) + if safe == "" { + return "campaign" + } + return safe +} + +// resolveEffectiveReportConfig merges a company config with the global default +// config. the enabled and send-on-finish decisions always come from the company +// config; each delivery field uses the company value when set, otherwise the +// global default. +func resolveEffectiveReportConfig(company, global *model.CompanyReportConfig) *model.CompanyReportConfig { + uuidSet := func(v nullable.Nullable[uuid.UUID]) bool { return v.IsSpecified() && !v.IsNull() } + emailSet := func(v nullable.Nullable[vo.Email]) bool { return v.IsSpecified() && !v.IsNull() } + strSet := func(v nullable.Nullable[string]) bool { + return v.IsSpecified() && !v.IsNull() && strings.TrimSpace(v.MustGet()) != "" + } + + eff := &model.CompanyReportConfig{ + Enabled: company.Enabled, + SendOnFinish: company.SendOnFinish, + RecipientGroupID: company.RecipientGroupID, + SMTPConfigurationID: company.SMTPConfigurationID, + SenderEmail: company.SenderEmail, + EmailSubject: company.EmailSubject, + EmailBody: company.EmailBody, + } + if global == nil { + return eff + } + if !uuidSet(eff.RecipientGroupID) { + eff.RecipientGroupID = global.RecipientGroupID + } + if !uuidSet(eff.SMTPConfigurationID) { + eff.SMTPConfigurationID = global.SMTPConfigurationID + } + if !emailSet(eff.SenderEmail) { + eff.SenderEmail = global.SenderEmail + } + if !strSet(eff.EmailSubject) { + eff.EmailSubject = global.EmailSubject + } + if !strSet(eff.EmailBody) { + eff.EmailBody = global.EmailBody + } + return eff +} + +// SendCampaignReport renders the campaign report PDF and emails it to the +// company's configured recipient group using the configured SMTP. when onDemand +// is false the send is treated as the automatic on close delivery and silently +// skips when the company has not enabled it; when onDemand is true the caller +// receives an error explaining why delivery is not possible. +func (c *Campaign) SendCampaignReport( + ctx context.Context, + session *model.Session, + campaignID *uuid.UUID, + onDemand bool, +) error { + ae := NewAuditEvent("Campaign.SendCampaignReport", session) + ae.Details["campaignId"] = campaignID.String() + isAuthorized, authErr := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if authErr != nil && !errors.Is(authErr, errs.ErrAuthorizationFailed) { + c.LogAuthError(authErr) + return errs.Wrap(authErr) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + // global PDF report generation must be enabled + opt, _ := c.OptionService.GetOptionWithoutAuth(ctx, data.OptionKeyReportPDFEnabled) + if opt == nil || opt.Value.String() != "true" { + if onDemand { + return errs.NewCustomError(errors.New("PDF report generation is not enabled")) + } + return nil + } + // load the campaign to resolve its company + campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{WithCompany: true}) + if err != nil { + return errs.Wrap(err) + } + var companyIDPtr *uuid.UUID + if cid, cErr := campaign.CompanyID.Get(); cErr == nil { + companyIDPtr = &cid + } + // the enable decision is per company: the company must have its own config + companyConfig, err := c.CompanyReportConfigRepository.GetByCompanyID(ctx, companyIDPtr) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + if onDemand { + return errs.NewCustomError(errors.New("report delivery is not configured for this company")) + } + return nil + } + return errs.Wrap(err) + } + if !companyConfig.Enabled { + if onDemand { + return errs.NewCustomError(errors.New("report delivery is not enabled for this company")) + } + return nil + } + // for the automatic path the company must have opted into on close delivery + if !onDemand && !companyConfig.SendOnFinish { + return nil + } + // committed to attempting delivery from here: audit the outcome on every path + deliveryOutcome := "failed" + defer func() { + ae.Details["outcome"] = deliveryOutcome + ae.Details["onDemand"] = onDemand + c.AuditLogAuthorized(ae) + }() + // the global config supplies default delivery fields the company has not set + var globalConfig *model.CompanyReportConfig + if g, gErr := c.CompanyReportConfigRepository.GetByCompanyID(ctx, nil); gErr == nil { + globalConfig = g + } else if !errors.Is(gErr, gorm.ErrRecordNotFound) { + return errs.Wrap(gErr) + } + config := resolveEffectiveReportConfig(companyConfig, globalConfig) + + trigger := "on_finish" + if onDemand { + trigger = "on_demand" + } + campaignName := "" + if n, nErr := campaign.Name.Get(); nErr == nil { + campaignName = n.String() + } + // filled in once the effective config is validated and resolved; the log + // closure reads them by reference so it can record partial failures too + groupName := "" + senderEmailStr := "" + // writeReportSendLog records the outcome of this delivery attempt. logging must + // never break delivery, so its own failure is only logged. + writeReportSendLog := func(status string, recipients []string, sendErr error) { + errMsg := "" + if sendErr != nil { + errMsg = sendErr.Error() + } + logRow := &model.ReportSendLog{ + CompanyID: nullableUUIDPtr(companyIDPtr), + CampaignID: nullable.NewNullableWithValue(*campaignID), + CampaignName: campaignName, + GroupName: groupName, + Trigger: trigger, + Status: status, + RecipientCount: len(recipients), + Recipients: strings.Join(recipients, ", "), + SenderEmail: senderEmailStr, + ErrorMessage: errMsg, + } + if _, logErr := c.ReportSendLogRepository.Insert(ctx, logRow); logErr != nil { + c.Logger.Errorw("failed to write report send log", "error", logErr, "campaignID", campaignID.String()) + } + } + + // the effective config must be complete enough to deliver + if err := config.Validate(); err != nil { + writeReportSendLog("failed", nil, err) + if onDemand { + return errs.Wrap(err) + } + c.Logger.Warnw("skipping automatic report delivery, config incomplete", "error", err, "campaignID", campaignID.String()) + return nil + } + configID := companyConfig.ID.MustGet() + groupID := config.RecipientGroupID.MustGet() + smtpConfigID := config.SMTPConfigurationID.MustGet() + senderEmail := config.SenderEmail.MustGet() + senderEmailStr = senderEmail.String() + + // resolve the recipient group name now so the log keeps it even if the group is + // deleted later. an empty name means the group no longer exists. + if group, gErr := c.RecipientGroupRepository.GetByID(ctx, &groupID, &repository.RecipientGroupOption{}); gErr == nil && group != nil { + if n, nErr := group.Name.Get(); nErr == nil { + groupName = n.String() + } + } + + // build the report and render it to PDF + htmlContent, reportData, _, err := c.buildReportHTMLWithData(ctx, session, campaignID) + if err != nil { + writeReportSendLog("failed", nil, fmt.Errorf("failed to build report: %w", err)) + return errs.Wrap(err) + } + pdfBytes, err := remotebrowser.RenderHTMLToPDF(ctx, htmlContent, c.RemoteBrowserExecPath) + if err != nil { + c.Logger.Errorw("failed to render report PDF", "error", err) + writeReportSendLog("failed", nil, fmt.Errorf("failed to render report PDF: %w", err)) + return errs.Wrap(err) + } + // load the smtp configuration to send through + smtpConfig, err := c.SMTPConfigService.GetByID( + ctx, + session, + &smtpConfigID, + &repository.SMTPConfigurationOption{WithHeaders: true}, + ) + if err != nil { + c.Logger.Errorw("failed to load smtp configuration for report", "error", err) + writeReportSendLog("failed", nil, fmt.Errorf("smtp configuration unavailable: %w", err)) + return errs.Wrap(err) + } + // resolve the recipients of the group; the group may have been deleted + recipientResult, err := c.RecipientGroupRepository.GetRecipientsByGroupID( + ctx, + &groupID, + &repository.RecipientOption{}, + ) + if err != nil { + c.Logger.Errorw("failed to get report recipients", "error", err) + writeReportSendLog("failed", nil, errors.New("the configured recipient group is unavailable")) + if onDemand { + return errs.NewCustomError(errors.New("the configured recipient group is unavailable, it may have been deleted")) + } + return nil + } + toEmails := []string{} + for _, r := range recipientResult.Rows { + if email, eErr := r.Email.Get(); eErr == nil { + toEmails = append(toEmails, email.String()) + } + } + if len(toEmails) == 0 { + writeReportSendLog("failed", nil, errors.New("the recipient group has no recipients")) + if onDemand { + return errs.NewCustomError(errors.New("the selected recipient group has no recipients")) + } + c.Logger.Warnw("skipping report delivery, recipient group is empty", "groupID", groupID.String()) + return nil + } + // build the report email with the PDF attached + filename := fmt.Sprintf("report-%s.pdf", sanitizeReportFilename(campaignName)) + m := mail.NewMsg(mail.WithNoDefaultUserAgent()) + if err := m.EnvelopeFrom(senderEmail.String()); err != nil { + return errs.Wrap(err) + } + if err := m.From(senderEmail.String()); err != nil { + return errs.Wrap(err) + } + if err := m.To(toEmails...); err != nil { + return errs.Wrap(err) + } + if headers := smtpConfig.Headers; headers != nil { + for _, header := range headers { + m.SetGenHeader(mail.Header(header.Key.MustGet().String()), header.Value.MustGet().String()) + } + } + // the subject and body are configurable and support the same variables and + // template functions as the report template (e.g. {{.CompanyName}}, + // {{.CampaignName}}, dates and stats), with defaults when empty + subjectTmpl := defaultReportEmailSubject + if config.EmailSubject.IsSpecified() && !config.EmailSubject.IsNull() { + if v := config.EmailSubject.MustGet(); strings.TrimSpace(v) != "" { + subjectTmpl = v + } + } + bodyTmpl := defaultReportEmailBody + if config.EmailBody.IsSpecified() && !config.EmailBody.IsNull() { + if v := config.EmailBody.MustGet(); strings.TrimSpace(v) != "" { + bodyTmpl = v + } + } + subject := renderReportEmailField(c, subjectTmpl, defaultReportEmailSubject, reportData) + body := renderReportEmailField(c, bodyTmpl, defaultReportEmailBody, reportData) + m.Subject(subject) + m.SetBodyString("text/html", body) + if err := m.AttachReader(filename, bytes.NewReader(pdfBytes)); err != nil { + return errs.Wrap(err) + } + if err := c.SMTPConfigService.SendMessages(ctx, smtpConfig, m); err != nil { + c.Logger.Errorw("failed to send report email", "error", err) + writeReportSendLog("failed", toEmails, err) + return errs.Wrap(err) + } + writeReportSendLog("sent", toEmails, nil) + deliveryOutcome = "sent" + ae.Details["recipients"] = len(toEmails) + // record when the report was last delivered; the audit event is emitted by the + // deferred outcome handler + if err := c.CompanyReportConfigRepository.UpdateLastSentAt(ctx, &configID); err != nil { + c.Logger.Errorw("failed to update report last sent at", "error", err, "campaignID", campaignID.String()) + } + + return nil +} + +// nullableUUIDPtr converts a possibly nil uuid pointer into a nullable value +func nullableUUIDPtr(id *uuid.UUID) nullable.Nullable[uuid.UUID] { + if id == nil { + return nullable.NewNullNullable[uuid.UUID]() + } + return nullable.NewNullableWithValue(*id) } func buildReportData( diff --git a/backend/service/companyReportConfig.go b/backend/service/companyReportConfig.go new file mode 100644 index 0000000..3f3f0b8 --- /dev/null +++ b/backend/service/companyReportConfig.go @@ -0,0 +1,177 @@ +package service + +import ( + "context" + + "github.com/go-errors/errors" + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" + "gorm.io/gorm" +) + +// CompanyReportConfig is the service for report delivery configuration. a nil +// companyID refers to the global default config used as a fallback. +type CompanyReportConfig struct { + Common + CompanyReportConfigRepository *repository.CompanyReportConfig + ReportSendLogRepository *repository.ReportSendLog +} + +// reportConfigScope returns a human label for the config scope for audit logging +func reportConfigScope(companyID *uuid.UUID) string { + if companyID == nil { + return "global" + } + return companyID.String() +} + +// GetByCompanyID returns the report config for the given company +func (s *CompanyReportConfig) GetByCompanyID( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, +) (*model.CompanyReportConfig, error) { + ae := NewAuditEvent("CompanyReportConfig.GetByCompanyID", session) + ae.Details["scope"] = reportConfigScope(companyID) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + s.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + // get + config, err := s.CompanyReportConfigRepository.GetByCompanyID(ctx, companyID) + if err != nil { + s.Logger.Errorw("failed to get report config by company id", "error", err) + return nil, errs.Wrap(err) + } + // no audit on read + + return config, nil +} + +// Upsert creates or updates the report delivery configuration for the given company +func (s *CompanyReportConfig) Upsert( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, + incoming *model.CompanyReportConfig, +) (*model.CompanyReportConfig, error) { + ae := NewAuditEvent("CompanyReportConfig.Upsert", session) + ae.Details["scope"] = reportConfigScope(companyID) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + s.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + // delivery fields are not required at save time: a company config may enable + // delivery while inheriting unset fields from the global default, and the + // global config is itself only a set of defaults. completeness is enforced on + // the effective (merged) config when a report is actually sent. + // a nil companyID targets the global default config. enabling and on-finish + // delivery are per company decisions, so they are never stored on the global + // config which only supplies default fields. + if companyID == nil { + incoming.CompanyID = nullable.NewNullNullable[uuid.UUID]() + incoming.Enabled = false + incoming.SendOnFinish = false + } else { + incoming.CompanyID = nullable.NewNullableWithValue(*companyID) + } + // check if a config already exists for this scope + existing, err := s.CompanyReportConfigRepository.GetByCompanyID(ctx, companyID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Logger.Errorw("failed to look up existing report config", "error", err) + return nil, errs.Wrap(err) + } + if existing != nil { + existingID, idErr := existing.ID.Get() + if idErr != nil { + s.Logger.Errorw("report config has no id", "error", idErr) + return nil, errs.Wrap(idErr) + } + if err := s.CompanyReportConfigRepository.UpdateByID(ctx, &existingID, incoming); err != nil { + s.Logger.Errorw("failed to update report config", "error", err) + return nil, errs.Wrap(err) + } + ae.Details["id"] = existingID.String() + ae.Details["action"] = "update" + s.AuditLogAuthorized(ae) + return s.CompanyReportConfigRepository.GetByID(ctx, &existingID) + } + // create + id, err := s.CompanyReportConfigRepository.Insert(ctx, incoming) + if err != nil { + s.Logger.Errorw("failed to insert report config", "error", err) + return nil, errs.Wrap(err) + } + ae.Details["id"] = id.String() + ae.Details["action"] = "insert" + s.AuditLogAuthorized(ae) + + return s.CompanyReportConfigRepository.GetByID(ctx, id) +} + +// DeleteByCompanyID removes the report configuration for the given company +func (s *CompanyReportConfig) DeleteByCompanyID( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, +) error { + ae := NewAuditEvent("CompanyReportConfig.DeleteByCompanyID", session) + ae.Details["scope"] = reportConfigScope(companyID) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + s.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + // delete + if err := s.CompanyReportConfigRepository.DeleteByCompanyID(ctx, companyID); err != nil { + s.Logger.Errorw("failed to delete report config", "error", err) + return errs.Wrap(err) + } + s.AuditLogAuthorized(ae) + + return nil +} + +// ListLogByCompanyID returns the report delivery log for a company using pagination +func (s *CompanyReportConfig) ListLogByCompanyID( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, + options *repository.ReportSendLogOption, +) (*model.Result[model.ReportSendLog], error) { + ae := NewAuditEvent("CompanyReportConfig.ListLogByCompanyID", session) + ae.Details["scope"] = reportConfigScope(companyID) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + s.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + // no audit on read + return s.ReportSendLogRepository.GetAllByCompanyID(ctx, companyID, options) +} diff --git a/backend/service/smtpConfiguration.go b/backend/service/smtpConfiguration.go index 4d0809c..0f4351d 100644 --- a/backend/service/smtpConfiguration.go +++ b/backend/service/smtpConfiguration.go @@ -625,3 +625,111 @@ func (s *SMTPConfiguration) DeleteByID( return nil } + +// SendMessages dials the SMTP server described by smtpConfig and sends the given +// messages over a single connection, applying the same authentication fallback +// used by campaign and test sending. it performs no authorization check; callers +// must authorize before building messages. +func (s *SMTPConfiguration) SendMessages( + ctx context.Context, + smtpConfig *model.SMTPConfiguration, + messages ...*mail.Msg, +) error { + if len(messages) == 0 { + return nil + } + smtpPort, err := smtpConfig.Port.Get() + if err != nil { + s.Logger.Errorw("failed to get smtp port", "error", err) + return errs.Wrap(err) + } + smtpHost, err := smtpConfig.Host.Get() + if err != nil { + s.Logger.Errorw("failed to get smtp host", "error", err) + return errs.Wrap(err) + } + smtpIgnoreCertErrors, err := smtpConfig.IgnoreCertErrors.Get() + if err != nil { + s.Logger.Errorw("failed to get smtp ignore cert errors", "error", err) + return errs.Wrap(err) + } + emailOptions := []mail.Option{ + mail.WithPort(smtpPort.Int()), + mail.WithTLSConfig( + &tls.Config{ + ServerName: smtpHost.String(), + // #nosec + InsecureSkipVerify: smtpIgnoreCertErrors, + }, + ), + } + username, err := smtpConfig.Username.Get() + if err != nil { + s.Logger.Errorw("failed to get smtp username", "error", err) + return errs.Wrap(err) + } + password, err := smtpConfig.Password.Get() + if err != nil { + s.Logger.Errorw("failed to get smtp password", "error", err) + return errs.Wrap(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)) + } + } + var mc *mail.Client + 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(ctx, messages...) + // on auth error fall back to 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 := 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(ctx, messages...) + } + } else { + // no credentials provided, try without authentication + 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(ctx, messages...) + } + if err != nil { + s.Logger.Errorw("failed to send messages", "error", err) + for _, m := range messages { + if m.HasSendError() { + return m.SendError() + } + } + return errs.Wrap(err) + } + return nil +} diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index c320402..59a4d81 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -1290,6 +1290,53 @@ export class API { return await postJSON(this.getPath(`/company/scim/${companyID}/restore`), {}); } }, + + reportConfig: { + // path for a scope: a company id, or the global default when companyID is null + scopePath: (companyID) => + companyID ? `/company/report-config/${companyID}` : `/report-config`, + // get the report delivery config for a company, or the global default when companyID is null + getByCompanyID: async (companyID) => { + return await getJSON(this.getPath(this.company.reportConfig.scopePath(companyID))); + }, + // create or update the report delivery config for a company or the global default + upsert: async ( + companyID, + { + enabled, + sendOnFinish, + recipientGroupID, + smtpConfigurationID, + senderEmail, + emailSubject, + emailBody + } + ) => { + return await postJSON(this.getPath(this.company.reportConfig.scopePath(companyID)), { + enabled: enabled, + sendOnFinish: sendOnFinish, + recipientGroupID: recipientGroupID ?? null, + smtpConfigurationID: smtpConfigurationID ?? null, + senderEmail: senderEmail ?? null, + emailSubject: emailSubject ?? '', + emailBody: emailBody ?? '' + }); + }, + // delete the report delivery config for a company or the global default + delete: async (companyID) => { + return await deleteJSON(this.getPath(this.company.reportConfig.scopePath(companyID))); + }, + // get the report delivery log for a company using pagination + getLog: async (companyID, options) => { + return await getJSON( + this.getPath(`/company/report-config/${companyID}/log?${appendQuery(options)}`) + ); + }, + // generate the report for a campaign and email it to the configured group now + sendNow: async (campaignID) => { + return await postJSON(this.getPath(`/report-config/send/${campaignID}`), {}); + } + }, /** * Get the auto-prune orphaned recipients setting for a company. * Returns only the per-company enabled flag. diff --git a/frontend/src/lib/components/company/CompanyReportDeliveryLog.svelte b/frontend/src/lib/components/company/CompanyReportDeliveryLog.svelte new file mode 100644 index 0000000..ab98bdc --- /dev/null +++ b/frontend/src/lib/components/company/CompanyReportDeliveryLog.svelte @@ -0,0 +1,93 @@ + + +Loading...
++ PDF report generation is disabled. Enable it under Settings → Reports before configuring + automatic delivery. +
++ Email the campaign report PDF to a recipient group, on demand or automatically when a + campaign is closed. + {#if isGlobal} + These are the global defaults used for any field a company does not set itself. + {:else} + Leave a field empty to use the global default. Enabling delivery is decided here, per + company. + {/if} +
+ + {#if config && !isGlobal} +Enabled
++ Turn report delivery on for this company. +
++ Send automatically on finish +
++ Deliver the report when a campaign is closed. When off, send on demand only. +
++ Host {selectedSmtp.host} +
+ {#if selectedSmtp.username} ++ Username + {selectedSmtp.username} +
+ {/if} +Email body
++ Subject and body support the same variables as the report template, such as {'{{.CompanyName}}'}, + {' '}{'{{.CampaignName}}'} and {'{{.ReportDate}}'}. + {isGlobal + ? 'The default is shown and can be edited.' + : 'Leave empty to use the global default.'} +
++ Are you sure you want to delete the report delivery configuration for + {isGlobal ? 'the global default' : company?.name}? +
++ PDF report generation is disabled. Enable it under Settings → Reports to configure + report templates and delivery. +
+@@ -303,7 +328,29 @@ >
+ Email the campaign report PDF to a recipient group, on demand or when a campaign is + closed. +
++ Reports sent for this company, including automatic and on demand deliveries. +
+Default HTML template used when generating campaign PDF reports. Companies without their own @@ -187,10 +191,24 @@
+ Default delivery settings used to email campaign reports. Companies without their own + configuration fall back to this. +
+