Browse Source

email action: allow to configure Bcc

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 2 years ago
parent
commit
8f934f7c82

+ 1 - 1
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)

+ 1 - 1
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))

+ 18 - 9
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

+ 9 - 4
internal/common/protocol_test.go

@@ -3576,6 +3576,7 @@ func TestEventRule(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"[email protected]", "[email protected]"},
+				Bcc:        []string{"[email protected]"},
 				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, "[email protected]"))
 		assert.True(t, util.Contains(email.To, "[email protected]"))
+		assert.True(t, util.Contains(email.To, "[email protected]"))
 		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, "[email protected]"))
 		assert.True(t, util.Contains(email.To, "[email protected]"))
+		assert.True(t, util.Contains(email.To, "[email protected]"))
 		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, "[email protected]"))
 		assert.True(t, util.Contains(email.To, "[email protected]"))
+		assert.True(t, util.Contains(email.To, "[email protected]"))
 		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, "[email protected]"))
 	assert.True(t, util.Contains(email.To, "[email protected]"))
+	assert.True(t, util.Contains(email.To, "[email protected]"))
 	assert.Contains(t, email.Data, `Subject: New "delete" from "admin"`)
 	_, err = httpdtest.RemoveEventRule(rule3, http.StatusOK)
 	assert.NoError(t, err)

+ 15 - 0
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,

+ 1 - 1
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

+ 1 - 1
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))

+ 9 - 0
internal/httpd/httpd_test.go

@@ -1708,6 +1708,7 @@ func TestBasicActionRulesHandling(t *testing.T) {
 	a.Options = dataprovider.BaseEventActionOptions{
 		EmailConfig: dataprovider.EventActionEmailConfig{
 			Recipients:  []string{"[email protected]"},
+			Bcc:         []string{"[email protected]"},
 			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{"[email protected]"}
+	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{"[email protected]", "[email protected]"},
+		Bcc:         []string{"[email protected]"},
 		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", "[email protected],  [email protected]")
+	form.Set("email_bcc", "[email protected]")
 	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)

+ 1 - 0
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"),

+ 8 - 0
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")
 	}

+ 14 - 9
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

+ 4 - 0
openapi/openapi.yaml

@@ -7085,6 +7085,10 @@ components:
           type: array
           items:
             type: string
+        bcc:
+          type: array
+          items:
+            type: string
         subject:
           type: string
         body:

+ 17 - 6
templates/webadmin/eventaction.html

@@ -453,18 +453,29 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             </div>
 
             <div class="form-group row action-type action-smtp">
-                <label for="idEmailRecipients" class="col-sm-2 col-form-label">Email recipients</label>
+                <label for="idEmailRecipients" class="col-sm-2 col-form-label">To</label>
                 <div class="col-sm-10">
                     <textarea class="form-control" id="idEmailRecipients" name="email_recipients" rows="2" placeholder=""
                         aria-describedby="smtpRecipientsHelpBlock">{{.Action.Options.EmailConfig.GetRecipientsAsString}}</textarea>
                     <small id="smtpRecipientsHelpBlock" class="form-text text-muted">
-                        Comma separated email recipients. Placeholders are supported
+                        Comma separated recipients. Placeholders are supported
                     </small>
                 </div>
             </div>
 
             <div class="form-group row action-type action-smtp">
-                <label for="idEmailSubject" class="col-sm-2 col-form-label">Email subject</label>
+                <label for="idEmailBcc" class="col-sm-2 col-form-label">Bcc</label>
+                <div class="col-sm-10">
+                    <textarea class="form-control" id="idEmailBcc" name="email_bcc" rows="2" placeholder=""
+                        aria-describedby="smtpBccHelpBlock">{{.Action.Options.EmailConfig.GetBccAsString}}</textarea>
+                    <small id="smtpBccHelpBlock" class="form-text text-muted">
+                        Comma separated Bcc addresses. Placeholders are supported
+                    </small>
+                </div>
+            </div>
+
+            <div class="form-group row action-type action-smtp">
+                <label for="idEmailSubject" class="col-sm-2 col-form-label">Subject</label>
                 <div class="col-sm-10">
                     <input type="text" class="form-control" id="idEmailSubject" name="email_subject" placeholder=""
                         value="{{.Action.Options.EmailConfig.Subject}}" maxlength="255" aria-describedby="emailSubjectHelpBlock">
@@ -475,7 +486,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             </div>
 
             <div class="form-group row action-type action-smtp">
-                <label for="idEmailContentType" class="col-sm-2 col-form-label">Email content type</label>
+                <label for="idEmailContentType" class="col-sm-2 col-form-label">Content type</label>
                 <div class="col-sm-10">
                     <select class="form-control selectpicker" id="idEmailContentType" name="email_content_type">
                         <option value="0" {{ if eq .Action.Options.EmailConfig.ContentType 0 }}selected{{end}}>Text/plain</option>
@@ -485,7 +496,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             </div>
 
             <div class="form-group row action-type action-smtp">
-                <label for="idEmailBody" class="col-sm-2 col-form-label">Email body</label>
+                <label for="idEmailBody" class="col-sm-2 col-form-label">Body</label>
                 <div class="col-sm-10">
                     <textarea class="form-control" id="idEmailBody" name="email_body" rows="4" placeholder=""
                         aria-describedby="smtpBodyHelpBlock">{{.Action.Options.EmailConfig.Body}}</textarea>
@@ -496,7 +507,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             </div>
 
             <div class="form-group row action-type action-smtp">
-                <label for="idEmailAttachments" class="col-sm-2 col-form-label">Email attachments</label>
+                <label for="idEmailAttachments" class="col-sm-2 col-form-label">Attachments</label>
                 <div class="col-sm-10">
                     <textarea class="form-control" id="idEmailAttachments" name="email_attachments" rows="2" placeholder=""
                         aria-describedby="smtpAttachmentsHelpBlock">{{.Action.Options.EmailConfig.GetAttachmentsAsString}}</textarea>