added report delivery

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-06-13 12:46:49 +02:00
parent a091da3de8
commit d1f100968e
23 changed files with 2195 additions and 21 deletions
+14
View File
@@ -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).
+15 -8
View File
@@ -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,
}
}
+4
View File
@@ -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},
}
+13
View File
@@ -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,
+240
View File
@@ -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{})
}
+45
View File
@@ -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
}
+40
View File
@@ -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
}
+1
View File
@@ -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())
+78
View File
@@ -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
}
+47
View File
@@ -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
}
+220
View File
@@ -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,
}
}
+110
View File
@@ -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,
}
}
+2
View File
@@ -59,6 +59,8 @@ func initialInstallAndSeed(
&database.OAuthState{},
&database.MicrosoftDeviceCode{},
&database.CompanyScimConfig{},
&database.CompanyReportConfig{},
&database.ReportSendLog{},
&database.RemoteBrowser{},
&database.ReportTemplate{},
}
+382 -11
View File
@@ -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(
+177
View File
@@ -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)
}
+108
View File
@@ -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
}
+47
View File
@@ -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}
+49 -1
View File
@@ -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}