email action: allow to configure Bcc

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2023-05-25 19:55:27 +02:00
parent b2781e0bfc
commit 8f934f7c82
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
13 changed files with 99 additions and 32 deletions

View file

@ -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)

View file

@ -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))

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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))

View file

@ -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)

View file

@ -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"),

View file

@ -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")
}

View file

@ -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

View file

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

View file

@ -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>