mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-04 11:27:57 +02:00
added report delivery
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ func initialInstallAndSeed(
|
||||
&database.OAuthState{},
|
||||
&database.MicrosoftDeviceCode{},
|
||||
&database.CompanyScimConfig{},
|
||||
&database.CompanyReportConfig{},
|
||||
&database.ReportSendLog{},
|
||||
&database.RemoteBrowser{},
|
||||
&database.ReportTemplate{},
|
||||
}
|
||||
|
||||
+382
-11
@@ -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 = "<p>The phishing simulation report for <strong>{{.CampaignName}}</strong> is attached.</p>"
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
import TableCell from '$lib/components/table/TableCell.svelte';
|
||||
|
||||
// the company whose delivery log is shown
|
||||
export let companyId;
|
||||
|
||||
const tableURLParams = newTableURLParams({
|
||||
sortBy: 'date',
|
||||
sortOrder: 'desc',
|
||||
prefix: 'log',
|
||||
noScroll: true
|
||||
});
|
||||
|
||||
let rows = [];
|
||||
let hasNextPage = false;
|
||||
let isLoading = false;
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
isLoading = true;
|
||||
const res = await api.company.reportConfig.getLog(companyId, tableURLParams);
|
||||
if (res.success) {
|
||||
rows = res.data?.rows ?? [];
|
||||
hasNextPage = !!res.data?.hasNextPage;
|
||||
} else {
|
||||
addToast(res.error ?? 'Failed to load delivery log', 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to load delivery log', e);
|
||||
addToast('Failed to load delivery log', 'Error');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
tableURLParams.onChange(refresh);
|
||||
return () => tableURLParams.unsubscribe();
|
||||
});
|
||||
|
||||
const triggerLabel = (t) =>
|
||||
t === 'on_demand' ? 'On demand' : t === 'on_finish' ? 'On finish' : t;
|
||||
</script>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{ column: 'Date', size: 'medium' },
|
||||
{ column: 'Campaign', size: 'large' },
|
||||
{ column: 'Group', size: 'medium' },
|
||||
{ column: 'Trigger', size: 'small' },
|
||||
{ column: 'Status', size: 'small' },
|
||||
{ column: 'Recipients', size: 'small' }
|
||||
]}
|
||||
sortable={['Date', 'Campaign', 'Group', 'Trigger', 'Status', 'Recipients']}
|
||||
hasData={!!rows.length}
|
||||
{hasNextPage}
|
||||
plural="deliveries"
|
||||
pagination={tableURLParams}
|
||||
isGhost={isLoading}
|
||||
hasActions={false}
|
||||
>
|
||||
{#each rows as row}
|
||||
<TableRow>
|
||||
<TableCell value={row.createdAt} isDate />
|
||||
<TableCell value={row.campaignName || '-'} />
|
||||
<TableCell value={row.groupName || '-'} />
|
||||
<TableCell value={triggerLabel(row.trigger)} />
|
||||
<TableCell>
|
||||
<div>
|
||||
<span
|
||||
class="inline-block text-xs font-semibold px-2 py-1 rounded-full
|
||||
{row.status === 'sent'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'}"
|
||||
title={row.status !== 'sent' ? row.errorMessage : ''}
|
||||
>
|
||||
{row.status === 'sent' ? 'Sent' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span title={row.recipients}>{row.recipientCount}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
@@ -1,13 +1,25 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { topMenu } from '$lib/consts/navigation';
|
||||
import { shouldHideMenuItem } from '$lib/utils/common';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import ConditionalDisplay from '../ConditionalDisplay.svelte';
|
||||
export let logout;
|
||||
export let visible = false;
|
||||
export let toggleChangeCompanyModal;
|
||||
|
||||
// show a link to the current company's settings when in company view mode
|
||||
const appState = AppStateService.instance;
|
||||
let isCompanyContext = false;
|
||||
let companyID = null;
|
||||
const unsubscribeContext = appState.subscribe((s) => {
|
||||
isCompanyContext = s.context.current === AppStateService.CONTEXT.COMPANY;
|
||||
companyID = s.context.companyID;
|
||||
});
|
||||
onDestroy(unsubscribeContext);
|
||||
|
||||
const icons = {
|
||||
profile: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
@@ -133,6 +145,24 @@
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</ConditionalDisplay>
|
||||
{#if item.label === 'Settings' && isCompanyContext && companyID}
|
||||
<a
|
||||
class="flex items-center pl-5 py-2 text-white transition-colors duration-200 {$page.url
|
||||
.pathname === `/company/${companyID}`
|
||||
? 'bg-active-blue shadow-md dark:bg-active-blue'
|
||||
: 'hover:shadow-md hover:bg-highlight-blue/80 dark:hover:bg-highlight-blue/20'}"
|
||||
draggable="false"
|
||||
on:click={() => {
|
||||
visible = false;
|
||||
}}
|
||||
href={`/company/${companyID}`}
|
||||
>
|
||||
<div class="flex-shrink-0 mr-3 text-white dark:text-highlight-blue">
|
||||
{@html getTopMenuIcon('Settings')}
|
||||
</div>
|
||||
<span>Company Settings</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
<button
|
||||
on:click={logout}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
<script>
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import Modal from '../Modal.svelte';
|
||||
import Alert from '../Alert.svelte';
|
||||
import TextField from '../TextField.svelte';
|
||||
import TextFieldSelect from '../TextFieldSelect.svelte';
|
||||
import FormButton from '../FormButton.svelte';
|
||||
import SimpleCodeEditor from '../editor/SimpleCodeEditor.svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { fetchAllRows } from '$lib/utils/api-utils.js';
|
||||
|
||||
// external
|
||||
export let visible = false;
|
||||
/** @type {{ id: string, name: string } | null} */
|
||||
export let company = null;
|
||||
// when true the modal edits the global default config (no company)
|
||||
export let isGlobal = false;
|
||||
|
||||
// the scope id passed to the api: a company id, or null for the global default
|
||||
$: companyId = isGlobal ? null : (company?.id ?? null);
|
||||
$: ready = isGlobal || !!company;
|
||||
|
||||
// defaults used by the server when no subject or body is set; mirrored here so
|
||||
// the editor shows what will actually be sent. keep in sync with the backend
|
||||
// defaultReportEmailSubject and defaultReportEmailBody constants.
|
||||
const DEFAULT_EMAIL_SUBJECT = 'Campaign report: {{.CampaignName}}';
|
||||
const DEFAULT_EMAIL_BODY =
|
||||
'<p>The phishing simulation report for <strong>{{.CampaignName}}</strong> is attached.</p>';
|
||||
|
||||
// local state
|
||||
let config = null;
|
||||
let pdfEnabled = false;
|
||||
let recipientGroupOptions = [];
|
||||
let smtpOptions = [];
|
||||
// full smtp rows kept so the From hint can show host and username
|
||||
let smtpByID = {};
|
||||
|
||||
let isLoading = false;
|
||||
let isSaving = false;
|
||||
let isDeleting = false;
|
||||
let isDeleteAlertVisible = false;
|
||||
|
||||
// form values
|
||||
let form = {
|
||||
enabled: false,
|
||||
sendOnFinish: false,
|
||||
recipientGroupID: '',
|
||||
smtpConfigurationID: '',
|
||||
senderEmail: '',
|
||||
emailSubject: '',
|
||||
emailBody: ''
|
||||
};
|
||||
|
||||
// reactive: reload when modal opens
|
||||
$: {
|
||||
if (visible && ready) {
|
||||
loadAll();
|
||||
}
|
||||
}
|
||||
|
||||
// reactive: clean up when modal closes
|
||||
$: {
|
||||
if (!visible) {
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
config = null;
|
||||
isDeleteAlertVisible = false;
|
||||
form = {
|
||||
enabled: false,
|
||||
sendOnFinish: false,
|
||||
recipientGroupID: '',
|
||||
smtpConfigurationID: '',
|
||||
senderEmail: '',
|
||||
emailSubject: '',
|
||||
emailBody: ''
|
||||
};
|
||||
};
|
||||
|
||||
const loadAll = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
await Promise.all([loadPdfEnabled(), loadConfig(), loadGroups(), loadSmtp()]);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPdfEnabled = async () => {
|
||||
try {
|
||||
const res = await api.option.get('report_pdf_enabled');
|
||||
pdfEnabled = res.success && res.data?.value === 'true';
|
||||
} catch (_) {
|
||||
pdfEnabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await api.company.reportConfig.getByCompanyID(companyId);
|
||||
if (res && res.success && res.data) {
|
||||
config = res.data;
|
||||
form = {
|
||||
enabled: !!config.enabled,
|
||||
sendOnFinish: !!config.sendOnFinish,
|
||||
recipientGroupID: config.recipientGroupID ?? '',
|
||||
smtpConfigurationID: config.smtpConfigurationID ?? '',
|
||||
senderEmail: config.senderEmail ?? '',
|
||||
emailSubject: config.emailSubject ?? '',
|
||||
emailBody: config.emailBody ?? ''
|
||||
};
|
||||
} else {
|
||||
config = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to load report config', e);
|
||||
config = null;
|
||||
}
|
||||
// on the global default, show the built-in defaults when nothing is set so
|
||||
// the user can see and edit what will be used. for a company, leave the
|
||||
// fields empty so they inherit the global default when unset.
|
||||
if (isGlobal) {
|
||||
if (!form.emailSubject) {
|
||||
form.emailSubject = DEFAULT_EMAIL_SUBJECT;
|
||||
}
|
||||
if (!form.emailBody) {
|
||||
form.emailBody = DEFAULT_EMAIL_BODY;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const groups = await fetchAllRows((options) => {
|
||||
return api.recipient.getAllGroups(options, companyId);
|
||||
});
|
||||
recipientGroupOptions = groups.map((g) => ({ value: g.id, label: g.name }));
|
||||
} catch (e) {
|
||||
console.error('failed to load recipient groups', e);
|
||||
recipientGroupOptions = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadSmtp = async () => {
|
||||
try {
|
||||
const configs = await fetchAllRows((options) => {
|
||||
return api.smtpConfiguration.getAll(options, companyId);
|
||||
});
|
||||
smtpOptions = configs.map((s) => ({ value: s.id, label: s.name }));
|
||||
smtpByID = configs.reduce((acc, s) => {
|
||||
acc[s.id] = s;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (e) {
|
||||
console.error('failed to load smtp configurations', e);
|
||||
smtpOptions = [];
|
||||
smtpByID = {};
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
isSaving = true;
|
||||
try {
|
||||
const res = await api.company.reportConfig.upsert(companyId, {
|
||||
enabled: form.enabled,
|
||||
sendOnFinish: form.sendOnFinish,
|
||||
recipientGroupID: form.recipientGroupID || null,
|
||||
smtpConfigurationID: form.smtpConfigurationID || null,
|
||||
senderEmail: form.senderEmail || null,
|
||||
emailSubject: form.emailSubject || '',
|
||||
emailBody: form.emailBody || ''
|
||||
});
|
||||
if (!res || !res.success) {
|
||||
addToast(res?.error ?? 'Failed to save report delivery', 'Error');
|
||||
return;
|
||||
}
|
||||
config = res.data;
|
||||
addToast('Report delivery saved', 'Success');
|
||||
visible = false;
|
||||
} catch (e) {
|
||||
console.error('failed to save report config', e);
|
||||
addToast('Failed to save report delivery', 'Error');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmDelete = async () => {
|
||||
isDeleting = true;
|
||||
try {
|
||||
const res = await api.company.reportConfig.delete(companyId);
|
||||
if (!res || !res.success) {
|
||||
addToast(res?.error ?? 'Failed to delete report delivery', 'Error');
|
||||
return { success: false };
|
||||
}
|
||||
resetState();
|
||||
addToast('Report delivery deleted', 'Success');
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
console.error('failed to delete report config', e);
|
||||
addToast('Failed to delete report delivery', 'Error');
|
||||
return { success: false };
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return 'Never';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
$: selectedSmtp = form.smtpConfigurationID ? smtpByID[form.smtpConfigurationID] : null;
|
||||
|
||||
$: isBusy = isSaving || isDeleting;
|
||||
</script>
|
||||
|
||||
<Modal headerText={isGlobal ? 'Default Report Delivery' : 'Report Delivery'} bind:visible>
|
||||
<div class="w-[640px] p-6 space-y-6">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
{:else if !pdfEnabled}
|
||||
<div
|
||||
class="rounded-md border border-amber-400 dark:border-amber-500/60 bg-amber-50 dark:bg-amber-900/20 p-4"
|
||||
>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
PDF report generation is disabled. Enable it under Settings → Reports before configuring
|
||||
automatic delivery.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
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}
|
||||
</p>
|
||||
|
||||
{#if config && !isGlobal}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-500">Last sent</span>
|
||||
<span class="text-gray-800 dark:text-gray-200">{formatDate(config.lastSentAt)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isGlobal}
|
||||
<!-- enabled toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Turn report delivery on for this company.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={form.enabled}
|
||||
disabled={isBusy}
|
||||
on:click={() => (form.enabled = !form.enabled)}
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50
|
||||
{form.enabled ? 'bg-cta-blue dark:bg-highlight-blue/80' : 'bg-gray-300 dark:bg-gray-600'}"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200
|
||||
{form.enabled ? 'translate-x-5' : 'translate-x-0'}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- send on finish toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Send automatically on finish
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Deliver the report when a campaign is closed. When off, send on demand only.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={form.sendOnFinish}
|
||||
disabled={isBusy || !form.enabled}
|
||||
on:click={() => (form.sendOnFinish = !form.sendOnFinish)}
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed
|
||||
{form.sendOnFinish ? 'bg-cta-blue dark:bg-highlight-blue/80' : 'bg-gray-300 dark:bg-gray-600'}"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200
|
||||
{form.sendOnFinish ? 'translate-x-5' : 'translate-x-0'}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- recipient group -->
|
||||
<TextFieldSelect
|
||||
id="report-recipient-group"
|
||||
placeholder="Select a recipient group"
|
||||
bind:value={form.recipientGroupID}
|
||||
options={recipientGroupOptions}>Recipient group</TextFieldSelect
|
||||
>
|
||||
|
||||
<!-- smtp configuration -->
|
||||
<TextFieldSelect
|
||||
id="report-smtp"
|
||||
placeholder="Select an SMTP configuration"
|
||||
bind:value={form.smtpConfigurationID}
|
||||
options={smtpOptions}>SMTP configuration</TextFieldSelect
|
||||
>
|
||||
{#if selectedSmtp}
|
||||
<div
|
||||
class="-mt-2 rounded-md bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700/60 px-3 py-2 text-xs text-gray-500 dark:text-gray-400 space-y-0.5"
|
||||
>
|
||||
<p>
|
||||
Host <span class="text-gray-700 dark:text-gray-300 font-mono">{selectedSmtp.host}</span>
|
||||
</p>
|
||||
{#if selectedSmtp.username}
|
||||
<p>
|
||||
Username
|
||||
<span class="text-gray-700 dark:text-gray-300 font-mono">{selectedSmtp.username}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- sender email (the From address) -->
|
||||
<TextField
|
||||
width="full"
|
||||
bind:value={form.senderEmail}
|
||||
toolTipText={'Used as the From address. Supports a display name, e.g. Acme Reports <noreply@acme.example.com>'}
|
||||
>Sender (From)</TextField
|
||||
>
|
||||
|
||||
<!-- email subject -->
|
||||
<TextField width="full" bind:value={form.emailSubject}>Email subject</TextField>
|
||||
|
||||
<!-- email body -->
|
||||
<div class="flex flex-col py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-400 py-2">Email body</p>
|
||||
<SimpleCodeEditor
|
||||
bind:value={form.emailBody}
|
||||
language="html"
|
||||
height="small"
|
||||
showVimToggle={true}
|
||||
showExpandButton={true}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
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.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div
|
||||
class="border-t border-gray-200 dark:border-gray-700/60 pt-4 flex gap-3 justify-end items-center"
|
||||
>
|
||||
{#if config}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
on:click={() => (isDeleteAlertVisible = true)}
|
||||
class="bg-red-600 dark:bg-red-700/80 hover:bg-red-500 dark:hover:bg-red-600/80 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 transition-colors duration-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
<FormButton size="medium" isSubmitting={isSaving} on:click={onSave}>Save</FormButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Alert
|
||||
headline="Delete Report Delivery"
|
||||
bind:visible={isDeleteAlertVisible}
|
||||
onConfirm={onConfirmDelete}
|
||||
verification="delete"
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete the report delivery configuration for
|
||||
<strong>{isGlobal ? 'the global default' : company?.name}</strong>?
|
||||
</p>
|
||||
</Alert>
|
||||
@@ -196,6 +196,9 @@
|
||||
const pdfOpt = await api.option.get('report_pdf_enabled');
|
||||
isReportPDFEnabled = pdfOpt.success && pdfOpt.data?.value === 'true';
|
||||
await refresh();
|
||||
if (isReportPDFEnabled) {
|
||||
await loadReportDeliveryConfig();
|
||||
}
|
||||
recipientTableUrlParams.onChange(refreshRecipients);
|
||||
eventsTableURLParams.onChange(refreshEvents);
|
||||
initialPageLoadComplete = true;
|
||||
@@ -942,6 +945,47 @@
|
||||
}
|
||||
};
|
||||
|
||||
let isSendReportModalVisible = false;
|
||||
// whether a usable report delivery config exists (company or global fallback)
|
||||
let canSendReport = false;
|
||||
|
||||
const loadReportDeliveryConfig = async () => {
|
||||
try {
|
||||
canSendReport = false;
|
||||
// enabling delivery is a per company decision; the global config holds
|
||||
// only default fields, so only the company config gates the action
|
||||
if (campaign?.companyID) {
|
||||
const res = await api.company.reportConfig.getByCompanyID(campaign.companyID);
|
||||
canSendReport = !!(res.success && res.data && res.data.enabled);
|
||||
}
|
||||
} catch (_) {
|
||||
canSendReport = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickSendReport = () => {
|
||||
if (!isReportPDFEnabled) {
|
||||
isReportPDFDisabledModalVisible = true;
|
||||
return;
|
||||
}
|
||||
isSendReportModalVisible = true;
|
||||
};
|
||||
|
||||
const onConfirmSendReport = async () => {
|
||||
try {
|
||||
const res = await api.company.reportConfig.sendNow($page.params.id);
|
||||
if (res && res.success) {
|
||||
addToast('Report sent to the configured group', 'Success');
|
||||
return { success: true };
|
||||
}
|
||||
addToast(res?.error ?? 'Failed to send report', 'Error');
|
||||
return { success: false, error: res?.error ?? 'Failed to send report' };
|
||||
} catch (e) {
|
||||
console.error('failed to send report', e);
|
||||
return { success: false, error: 'Failed to send report' };
|
||||
}
|
||||
};
|
||||
|
||||
const refreshRecipients = async (showIsLoading = true) => {
|
||||
try {
|
||||
if (showIsLoading) {
|
||||
@@ -1904,6 +1948,11 @@
|
||||
<IconButton variant="blue" icon="export" on:click={onClickGenerateReport}>
|
||||
Report
|
||||
</IconButton>
|
||||
{#if canSendReport}
|
||||
<IconButton variant="blue" icon="export" on:click={onClickSendReport}>
|
||||
Send Report
|
||||
</IconButton>
|
||||
{/if}
|
||||
<IconButton variant="green" icon="export" on:click={onClickExportEvents}>
|
||||
Events
|
||||
</IconButton>
|
||||
@@ -2946,6 +2995,17 @@
|
||||
automatically.
|
||||
</div>
|
||||
</Alert>
|
||||
<Alert
|
||||
headline="send report"
|
||||
bind:visible={isSendReportModalVisible}
|
||||
onConfirm={onConfirmSendReport}
|
||||
ok="Send"
|
||||
>
|
||||
<div class="mt-4 text-gray-700 dark:text-gray-200">
|
||||
Send the PDF report for <strong>{campaign?.name}</strong> to the recipient group configured for
|
||||
this company. <br />Configure recipients and SMTP under the company's Reports settings.
|
||||
</div>
|
||||
</Alert>
|
||||
<Alert
|
||||
headline="PDF reports disabled"
|
||||
bind:visible={isReportPDFDisabledModalVisible}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import ScimModal from '$lib/components/modal/ScimModal.svelte';
|
||||
import CompanyReportTemplateModal from '$lib/components/modal/CompanyReportTemplateModal.svelte';
|
||||
import CompanyReportDeliveryModal from '$lib/components/modal/CompanyReportDeliveryModal.svelte';
|
||||
import CompanyReportDeliveryLog from '$lib/components/company/CompanyReportDeliveryLog.svelte';
|
||||
import CompanyCustomStats from '$lib/components/company/CompanyCustomStats.svelte';
|
||||
|
||||
$: companyId = $page.params.id;
|
||||
@@ -38,9 +40,13 @@
|
||||
// SCIM status shown in the Integrations tab
|
||||
let scimStatus = 'none'; // 'none' | 'disabled' | 'enabled'
|
||||
|
||||
// whether PDF report generation is enabled globally; gates the Reports tab
|
||||
let reportPDFEnabled = false;
|
||||
|
||||
// modals
|
||||
let isScimModalVisible = false;
|
||||
let isReportTemplateModalVisible = false;
|
||||
let isReportDeliveryModalVisible = false;
|
||||
let isExportAlertVisible = false;
|
||||
let isDeleteAlertVisible = false;
|
||||
|
||||
@@ -73,7 +79,16 @@
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await Promise.all([loadCompany(), loadAutoPrune(), loadScimStatus()]);
|
||||
await Promise.all([loadCompany(), loadAutoPrune(), loadScimStatus(), loadReportPDFEnabled()]);
|
||||
};
|
||||
|
||||
const loadReportPDFEnabled = async () => {
|
||||
try {
|
||||
const res = await api.option.get('report_pdf_enabled');
|
||||
reportPDFEnabled = res.success && res.data?.value === 'true';
|
||||
} catch (_) {
|
||||
reportPDFEnabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCompany = async () => {
|
||||
@@ -292,6 +307,16 @@
|
||||
</SettingsCard>
|
||||
</div>
|
||||
{:else if active === 'reports'}
|
||||
{#if !reportPDFEnabled}
|
||||
<div
|
||||
class="rounded-md border border-amber-400 dark:border-amber-500/60 bg-amber-50 dark:bg-amber-900/20 p-4 max-w-xl"
|
||||
>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
PDF report generation is disabled. Enable it under Settings → Reports to configure
|
||||
report templates and delivery.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Report Template">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4">
|
||||
@@ -303,7 +328,29 @@
|
||||
>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Report Delivery">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4">
|
||||
Email the campaign report PDF to a recipient group, on demand or when a campaign is
|
||||
closed.
|
||||
</p>
|
||||
<div class="mt-auto flex justify-end">
|
||||
<FormButton size="medium" on:click={() => (isReportDeliveryModalVisible = true)}
|
||||
>Configure</FormButton
|
||||
>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Delivery Log</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4">
|
||||
Reports sent for this company, including automatic and on demand deliveries.
|
||||
</p>
|
||||
<CompanyReportDeliveryLog companyId={companyId} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else if active === 'data'}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Export">
|
||||
@@ -341,6 +388,7 @@
|
||||
|
||||
<ScimModal bind:visible={isScimModalVisible} {company} />
|
||||
<CompanyReportTemplateModal bind:visible={isReportTemplateModalVisible} {company} />
|
||||
<CompanyReportDeliveryModal bind:visible={isReportDeliveryModalVisible} {company} />
|
||||
|
||||
<Alert
|
||||
headline="Export Company Data"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import Editor from '$lib/components/editor/Editor.svelte';
|
||||
import CompanyReportDeliveryModal from '$lib/components/modal/CompanyReportDeliveryModal.svelte';
|
||||
|
||||
const appState = AppStateService.instance;
|
||||
$: isCompanyContext = appState.isCompanyContext();
|
||||
@@ -24,6 +25,9 @@
|
||||
let isReportPDFEnableModalVisible = false;
|
||||
let isTogglingReportPDF = false;
|
||||
|
||||
// global default report delivery
|
||||
let isReportDeliveryModalVisible = false;
|
||||
|
||||
// report template
|
||||
let isReportTemplateModalVisible = false;
|
||||
let reportTemplateContent = '';
|
||||
@@ -177,7 +181,7 @@
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
|
||||
{#if !isCompanyContext}
|
||||
{#if !isCompanyContext && isReportPDFEnabled}
|
||||
<SettingsCard title="Report Template">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Default HTML template used when generating campaign PDF reports. Companies without their own
|
||||
@@ -187,10 +191,24 @@
|
||||
<Button size={'large'} on:click={openReportTemplateModal}>Edit Template</Button>
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Report Delivery">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
|
||||
Default delivery settings used to email campaign reports. Companies without their own
|
||||
configuration fall back to this.
|
||||
</p>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button size={'large'} on:click={() => (isReportDeliveryModalVisible = true)}
|
||||
>Configure</Button
|
||||
>
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CompanyReportDeliveryModal bind:visible={isReportDeliveryModalVisible} isGlobal={true} />
|
||||
|
||||
{#if isReportTemplateModalVisible}
|
||||
<Modal
|
||||
bind:visible={isReportTemplateModalVisible}
|
||||
|
||||
Reference in New Issue
Block a user