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 .
- +
- Comma separated email recipients. Placeholders are supported + Comma separated recipients. Placeholders are supported
- + +
+ + + Comma separated Bcc addresses. Placeholders are supported + +
+
+ +
+
@@ -475,7 +486,7 @@ along with this program. If not, see .
- +
@@ -496,7 +507,7 @@ along with this program. If not, see .
- +