diff --git a/internal/cmd/smtptest.go b/internal/cmd/smtptest.go
index 876b5635..40915bcb 100644
--- a/internal/cmd/smtptest.go
+++ b/internal/cmd/smtptest.go
@@ -55,7 +55,7 @@ If the SMTP configuration is correct you should receive this email.`,
logger.ErrorToConsole("unable to initialize SMTP configuration: %v", err)
os.Exit(1)
}
- err = smtp.SendEmail([]string{smtpTestRecipient}, "SFTPGo - Testing Email Settings", "It appears your SFTPGo email is setup correctly!",
+ err = smtp.SendEmail([]string{smtpTestRecipient}, nil, "SFTPGo - Testing Email Settings", "It appears your SFTPGo email is setup correctly!",
smtp.EmailContentTypeTextPlain)
if err != nil {
logger.WarnToConsole("Error sending email: %v", err)
diff --git a/internal/common/dataretention.go b/internal/common/dataretention.go
index d3faf890..b53e9f46 100644
--- a/internal/common/dataretention.go
+++ b/internal/common/dataretention.go
@@ -389,7 +389,7 @@ func (c *RetentionCheck) sendEmailNotification(errCheck error) error {
subject = fmt.Sprintf("Retention check failed for user %q", c.conn.User.Username)
}
body := "Further details attached."
- err = smtp.SendEmail([]string{c.Email}, subject, body, smtp.EmailContentTypeTextPlain, files...)
+ err = smtp.SendEmail([]string{c.Email}, nil, subject, body, smtp.EmailContentTypeTextPlain, files...)
if err != nil {
c.conn.Log(logger.LevelError, "unable to notify retention check result via email: %v, elapsed: %s", err,
time.Since(startTime))
diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go
index 136a9d0f..a0769bd8 100644
--- a/internal/common/eventmanager.go
+++ b/internal/common/eventmanager.go
@@ -1441,6 +1441,20 @@ func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params *E
return err
}
+func getEmailAddressesWithReplacer(addrs []string, replacer *strings.Replacer) []string {
+ if len(addrs) == 0 {
+ return nil
+ }
+ recipients := make([]string, 0, len(addrs))
+ for _, recipient := range addrs {
+ rcpt := replaceWithReplacer(recipient, replacer)
+ if rcpt != "" {
+ recipients = append(recipients, rcpt)
+ }
+ }
+ return recipients
+}
+
func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *EventParams) error {
addObjectData := false
if params.Object != nil {
@@ -1452,13 +1466,8 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *Event
replacer := strings.NewReplacer(replacements...)
body := replaceWithReplacer(c.Body, replacer)
subject := replaceWithReplacer(c.Subject, replacer)
- recipients := make([]string, 0, len(c.Recipients))
- for _, recipient := range c.Recipients {
- rcpt := replaceWithReplacer(recipient, replacer)
- if rcpt != "" {
- recipients = append(recipients, rcpt)
- }
- }
+ recipients := getEmailAddressesWithReplacer(c.Recipients, replacer)
+ bcc := getEmailAddressesWithReplacer(c.Bcc, replacer)
startTime := time.Now()
var files []*mail.File
fileAttachments := make([]string, 0, len(c.Attachments))
@@ -1495,7 +1504,7 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *Event
}
files = append(files, res...)
}
- err := smtp.SendEmail(recipients, subject, body, smtp.EmailContentType(c.ContentType), files...)
+ err := smtp.SendEmail(recipients, bcc, subject, body, smtp.EmailContentType(c.ContentType), files...)
eventManagerLog(logger.LevelDebug, "executed email notification action, elapsed: %s, error: %v",
time.Since(startTime), err)
if err != nil {
@@ -2344,7 +2353,7 @@ func executePwdExpirationCheckForUser(user *dataprovider.User, config dataprovid
}
subject := "SFTPGo password expiration notification"
startTime := time.Now()
- if err := smtp.SendEmail([]string{user.Email}, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
+ if err := smtp.SendEmail([]string{user.Email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v, elapsed: %s",
user.Username, err, time.Since(startTime))
return err
diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go
index cdc9dde3..95d72d2d 100644
--- a/internal/common/protocol_test.go
+++ b/internal/common/protocol_test.go
@@ -3576,6 +3576,7 @@ func TestEventRule(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test1@example.com", "test2@example.com"},
+ Bcc: []string{"test3@example.com"},
Subject: `New "{{Event}}" from "{{Name}}" status {{StatusString}}`,
Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} Data: {{ObjectData}} {{ErrorString}}",
},
@@ -3771,9 +3772,10 @@ func TestEventRule(t *testing.T) {
return lastReceivedEmail.get().From != ""
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
- assert.Len(t, email.To, 2)
+ assert.Len(t, email.To, 3)
assert.True(t, util.Contains(email.To, "test1@example.com"))
assert.True(t, util.Contains(email.To, "test2@example.com"))
+ assert.True(t, util.Contains(email.To, "test3@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "upload" from "%s" status OK`, user.Username))
// test the failure action, we download a file that exceeds the transfer quota limit
err = writeSFTPFileNoCheck(path.Join("subdir1", testFileName), 1*1024*1024+65535, client)
@@ -3791,9 +3793,10 @@ func TestEventRule(t *testing.T) {
return lastReceivedEmail.get().From != ""
}, 3000*time.Millisecond, 100*time.Millisecond)
email = lastReceivedEmail.get()
- assert.Len(t, email.To, 2)
+ assert.Len(t, email.To, 3)
assert.True(t, util.Contains(email.To, "test1@example.com"))
assert.True(t, util.Contains(email.To, "test2@example.com"))
+ assert.True(t, util.Contains(email.To, "test3@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "download" from "%s" status KO`, user.Username))
assert.Contains(t, email.Data, `"download" failed`)
assert.Contains(t, email.Data, common.ErrReadQuotaExceeded.Error())
@@ -3827,9 +3830,10 @@ func TestEventRule(t *testing.T) {
return lastReceivedEmail.get().From != ""
}, 3000*time.Millisecond, 100*time.Millisecond)
email = lastReceivedEmail.get()
- assert.Len(t, email.To, 2)
+ assert.Len(t, email.To, 3)
assert.True(t, util.Contains(email.To, "test1@example.com"))
assert.True(t, util.Contains(email.To, "test2@example.com"))
+ assert.True(t, util.Contains(email.To, "test3@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "download" from "%s"`, user.Username))
}
// test upload action command with arguments
@@ -3869,9 +3873,10 @@ func TestEventRule(t *testing.T) {
return lastReceivedEmail.get().From != ""
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
- assert.Len(t, email.To, 2)
+ assert.Len(t, email.To, 3)
assert.True(t, util.Contains(email.To, "test1@example.com"))
assert.True(t, util.Contains(email.To, "test2@example.com"))
+ assert.True(t, util.Contains(email.To, "test3@example.com"))
assert.Contains(t, email.Data, `Subject: New "delete" from "admin"`)
_, err = httpdtest.RemoveEventRule(rule3, http.StatusOK)
assert.NoError(t, err)
diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go
index d2a741f5..0c1b5069 100644
--- a/internal/dataprovider/eventrule.go
+++ b/internal/dataprovider/eventrule.go
@@ -474,6 +474,7 @@ func (c EventActionCommandConfig) GetArgumentsAsString() string {
// EventActionEmailConfig defines the configuration options for SMTP event actions
type EventActionEmailConfig struct {
Recipients []string `json:"recipients,omitempty"`
+ Bcc []string `json:"bcc,omitempty"`
Subject string `json:"subject,omitempty"`
Body string `json:"body,omitempty"`
Attachments []string `json:"attachments,omitempty"`
@@ -485,6 +486,11 @@ func (c EventActionEmailConfig) GetRecipientsAsString() string {
return strings.Join(c.Recipients, ",")
}
+// GetBccAsString returns the list of bcc as comma separated string
+func (c EventActionEmailConfig) GetBccAsString() string {
+ return strings.Join(c.Bcc, ",")
+}
+
// GetAttachmentsAsString returns the list of attachments as comma separated string
func (c EventActionEmailConfig) GetAttachmentsAsString() string {
return strings.Join(c.Attachments, ",")
@@ -509,6 +515,12 @@ func (c *EventActionEmailConfig) validate() error {
return util.NewValidationError("invalid email recipients")
}
}
+ c.Bcc = util.RemoveDuplicates(c.Bcc, false)
+ for _, r := range c.Bcc {
+ if r == "" {
+ return util.NewValidationError("invalid email bcc")
+ }
+ }
if c.Subject == "" {
return util.NewValidationError("email subject is required")
}
@@ -897,6 +909,8 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
o.SetEmptySecretsIfNil()
emailRecipients := make([]string, len(o.EmailConfig.Recipients))
copy(emailRecipients, o.EmailConfig.Recipients)
+ emailBcc := make([]string, len(o.EmailConfig.Bcc))
+ copy(emailBcc, o.EmailConfig.Bcc)
emailAttachments := make([]string, len(o.EmailConfig.Attachments))
copy(emailAttachments, o.EmailConfig.Attachments)
cmdArgs := make([]string, len(o.CmdConfig.Args))
@@ -941,6 +955,7 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
},
EmailConfig: EventActionEmailConfig{
Recipients: emailRecipients,
+ Bcc: emailBcc,
Subject: o.EmailConfig.Subject,
ContentType: o.EmailConfig.ContentType,
Body: o.EmailConfig.Body,
diff --git a/internal/httpd/api_configs.go b/internal/httpd/api_configs.go
index ba164da4..473fd3b0 100644
--- a/internal/httpd/api_configs.go
+++ b/internal/httpd/api_configs.go
@@ -48,7 +48,7 @@ func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
req.Password = configs.SMTP.Password.GetPayload()
}
}
- if err := req.SendEmail([]string{req.Recipient}, "SFTPGo - Testing Email Settings",
+ if err := req.SendEmail([]string{req.Recipient}, nil, "SFTPGo - Testing Email Settings",
"It appears your SFTPGo email is setup correctly!", smtp.EmailContentTypeTextPlain); err != nil {
sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
return
diff --git a/internal/httpd/api_utils.go b/internal/httpd/api_utils.go
index bd62b655..f392e65c 100644
--- a/internal/httpd/api_utils.go
+++ b/internal/httpd/api_utils.go
@@ -693,7 +693,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
return util.NewGenericError("Unable to render password reset template")
}
startTime := time.Now()
- if err := smtp.SendEmail([]string{email}, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
+ if err := smtp.SendEmail([]string{email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v",
err, time.Since(startTime))
return util.NewGenericError(fmt.Sprintf("Unable to send confirmation code via email: %v", err))
diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go
index 3dca00fd..f405a971 100644
--- a/internal/httpd/httpd_test.go
+++ b/internal/httpd/httpd_test.go
@@ -1708,6 +1708,7 @@ func TestBasicActionRulesHandling(t *testing.T) {
a.Options = dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"email@example.com"},
+ Bcc: []string{"bcc@example.com"},
Subject: "Event: {{Event}}",
Body: "test mail body",
Attachments: []string{"/{{VirtualPath}}"},
@@ -2250,6 +2251,11 @@ func TestEventActionValidation(t *testing.T) {
assert.NoError(t, err)
assert.Contains(t, string(resp), "invalid email recipients")
action.Options.EmailConfig.Recipients = []string{"a@a.com"}
+ action.Options.EmailConfig.Bcc = []string{""}
+ _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
+ assert.NoError(t, err)
+ assert.Contains(t, string(resp), "invalid email bcc")
+ action.Options.EmailConfig.Bcc = nil
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "email subject is required")
@@ -21820,6 +21826,7 @@ func TestWebEventAction(t *testing.T) {
action.Type = dataprovider.ActionTypeEmail
action.Options.EmailConfig = dataprovider.EventActionEmailConfig{
Recipients: []string{"address1@example.com", "address2@example.com"},
+ Bcc: []string{"address3@example.com"},
Subject: "subject",
ContentType: 1,
Body: "body",
@@ -21827,6 +21834,7 @@ func TestWebEventAction(t *testing.T) {
}
form.Set("type", fmt.Sprintf("%d", action.Type))
form.Set("email_recipients", "address1@example.com, address2@example.com")
+ form.Set("email_bcc", "address3@example.com")
form.Set("email_subject", action.Options.EmailConfig.Subject)
form.Set("email_content_type", fmt.Sprintf("%d", action.Options.EmailConfig.ContentType))
form.Set("email_body", action.Options.EmailConfig.Body)
@@ -21843,6 +21851,7 @@ func TestWebEventAction(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, action.Type, actionGet.Type)
assert.Equal(t, action.Options.EmailConfig.Recipients, actionGet.Options.EmailConfig.Recipients)
+ assert.Equal(t, action.Options.EmailConfig.Bcc, actionGet.Options.EmailConfig.Bcc)
assert.Equal(t, action.Options.EmailConfig.Subject, actionGet.Options.EmailConfig.Subject)
assert.Equal(t, action.Options.EmailConfig.ContentType, actionGet.Options.EmailConfig.ContentType)
assert.Equal(t, action.Options.EmailConfig.Body, actionGet.Options.EmailConfig.Body)
diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go
index 71791e28..12ab8aeb 100644
--- a/internal/httpd/webadmin.go
+++ b/internal/httpd/webadmin.go
@@ -2326,6 +2326,7 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
},
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: getSliceFromDelimitedValues(r.Form.Get("email_recipients"), ","),
+ Bcc: getSliceFromDelimitedValues(r.Form.Get("email_bcc"), ","),
Subject: r.Form.Get("email_subject"),
ContentType: emailContentType,
Body: r.Form.Get("email_body"),
diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go
index d6bc6d22..fc585173 100644
--- a/internal/httpdtest/httpdtest.go
+++ b/internal/httpdtest/httpdtest.go
@@ -2659,6 +2659,14 @@ func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActi
return errors.New("email recipients content mismatch")
}
}
+ if len(expected.Bcc) != len(actual.Bcc) {
+ return errors.New("email bcc mismatch")
+ }
+ for _, v := range expected.Bcc {
+ if !util.Contains(actual.Bcc, v) {
+ return errors.New("email bcc content mismatch")
+ }
+ }
if expected.Subject != actual.Subject {
return errors.New("email subject mismatch")
}
diff --git a/internal/smtp/smtp.go b/internal/smtp/smtp.go
index 9512bd52..05af0e7e 100644
--- a/internal/smtp/smtp.go
+++ b/internal/smtp/smtp.go
@@ -104,7 +104,7 @@ func (c *activeConfig) Set(cfg *dataprovider.SMTPConfigs) {
}
}
-func (c *activeConfig) getSMTPClientAndMsg(to []string, subject, body string, contentType EmailContentType,
+func (c *activeConfig) getSMTPClientAndMsg(to, bcc []string, subject, body string, contentType EmailContentType,
attachments ...*mail.File,
) (*mail.Client, *mail.Msg, error) {
c.RLock()
@@ -114,11 +114,11 @@ func (c *activeConfig) getSMTPClientAndMsg(to []string, subject, body string, co
return nil, nil, errors.New("smtp: not configured")
}
- return c.config.getSMTPClientAndMsg(to, subject, body, contentType, attachments...)
+ return c.config.getSMTPClientAndMsg(to, bcc, subject, body, contentType, attachments...)
}
-func (c *activeConfig) sendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
- client, msg, err := c.getSMTPClientAndMsg(to, subject, body, contentType, attachments...)
+func (c *activeConfig) sendEmail(to, bcc []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
+ client, msg, err := c.getSMTPClientAndMsg(to, bcc, subject, body, contentType, attachments...)
if err != nil {
return err
}
@@ -286,7 +286,7 @@ func (c *Config) getMailClientOptions() []mail.Option {
return options
}
-func (c *Config) getSMTPClientAndMsg(to []string, subject, body string, contentType EmailContentType,
+func (c *Config) getSMTPClientAndMsg(to, bcc []string, subject, body string, contentType EmailContentType,
attachments ...*mail.File) (*mail.Client, *mail.Msg, error) {
version := version.Get()
msg := mail.NewMsg()
@@ -304,6 +304,11 @@ func (c *Config) getSMTPClientAndMsg(to []string, subject, body string, contentT
if err := msg.To(to...); err != nil {
return nil, nil, err
}
+ if len(bcc) > 0 {
+ if err := msg.Bcc(bcc...); err != nil {
+ return nil, nil, err
+ }
+ }
msg.Subject(subject)
msg.SetDate()
msg.SetMessageID()
@@ -326,8 +331,8 @@ func (c *Config) getSMTPClientAndMsg(to []string, subject, body string, contentT
}
// SendEmail tries to send an email using the specified parameters
-func (c *Config) SendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
- client, msg, err := c.getSMTPClientAndMsg(to, subject, body, contentType, attachments...)
+func (c *Config) SendEmail(to, bcc []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
+ client, msg, err := c.getSMTPClientAndMsg(to, bcc, subject, body, contentType, attachments...)
if err != nil {
return err
}
@@ -366,8 +371,8 @@ func RenderPasswordExpirationTemplate(buf *bytes.Buffer, data any) error {
}
// SendEmail tries to send an email using the specified parameters.
-func SendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
- return config.sendEmail(to, subject, body, contentType, attachments...)
+func SendEmail(to, bcc []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
+ return config.sendEmail(to, bcc, subject, body, contentType, attachments...)
}
// ReloadProviderConf reloads the configuration from the provider
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 5d1581ec..0bbe2cb8 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -7085,6 +7085,10 @@ components:
type: array
items:
type: string
+ bcc:
+ type: array
+ items:
+ type: string
subject:
type: string
body:
diff --git a/templates/webadmin/eventaction.html b/templates/webadmin/eventaction.html
index 7b4736e2..b4ae9fc0 100644
--- a/templates/webadmin/eventaction.html
+++ b/templates/webadmin/eventaction.html
@@ -453,18 +453,29 @@ along with this program. If not, see