eventmanager: allow to add attachments to email actions

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-08-22 19:04:17 +02:00
parent 3e8254e398
commit 6777008aec
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
13 changed files with 386 additions and 28 deletions

View file

@ -59,6 +59,7 @@ If you are running multiple SFTPGo instances connected to the same data provider
Some actions are not supported for some triggers, rules containing incompatible actions are skipped at runtime:
- `Filesystem events`, folder quota reset cannot be executed, we don't have a direct way to get the affected folder.
- `Provider events`, user quota reset, transfer quota reset, data retention check and filesystem actions can be executed only if we modify a user. They will be executed for the affected user. Folder quota reset can be executed only for folders. Filesystem actions are not executed for `delete` user events because the actions is executed after the user deletion.
- `Provider events`, user quota reset, transfer quota reset, data retention check and filesystem actions can be executed only if a user is updated. They will be executed for the affected user. Folder quota reset can be executed only for folders. Filesystem actions are not executed for `delete` user events because the actions is executed after the user deletion.
- `IP Blocked`, user quota reset, folder quota reset, transfer quota reset, data retention check and filesystem actions cannot be executed, we only have an IP.
- `Certificate`, user quota reset, folder quota reset, transfer quota reset, data retention check and filesystem actions cannot be executed.
- `Email with attachments` are supported for filesystem events and provider events if a user is updated. We need a user to get the files to attach.

2
go.mod
View file

@ -155,7 +155,7 @@ require (
golang.org/x/tools v0.1.12 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa // indirect
google.golang.org/genproto v0.0.0-20220822141531-cb6d359b7ced // indirect
google.golang.org/grpc v1.48.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

4
go.sum
View file

@ -1229,8 +1229,8 @@ google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljW
google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa h1:Ux9yJCyf598uEniFPSyp8g1jtGTt77m+lzYyVgrWQaQ=
google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220822141531-cb6d359b7ced h1:ZjPHtZXcQ2EaCGgKb4iX6m/4q2HpogJuLR31in3Zp50=
google.golang.org/genproto v0.0.0-20220822141531-cb6d359b7ced/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=

View file

@ -32,6 +32,7 @@ import (
"github.com/robfig/cron/v3"
"github.com/rs/xid"
mail "github.com/xhit/go-simple-mail/v2"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/logger"
@ -42,7 +43,8 @@ import (
)
const (
ipBlockedEventName = "IP Blocked"
ipBlockedEventName = "IP Blocked"
emailAttachmentsMaxSize = int64(10 * 1024 * 1024)
)
var (
@ -426,13 +428,21 @@ func (p *EventParams) getUsers() ([]dataprovider.User, error) {
if p.sender == "" {
return dataprovider.DumpUsers()
}
user, err := dataprovider.UserExists(p.sender)
user, err := p.getUserFromSender()
if err != nil {
return nil, fmt.Errorf("error getting user %q: %w", p.sender, err)
return nil, err
}
return []dataprovider.User{user}, nil
}
func (p *EventParams) getUserFromSender() (dataprovider.User, error) {
user, err := dataprovider.UserExists(p.sender)
if err != nil {
return user, fmt.Errorf("error getting user %q: %w", p.sender, err)
}
return user, nil
}
func (p *EventParams) getFolders() ([]vfs.BaseVirtualFolder, error) {
if p.sender == "" {
return dataprovider.DumpFolders()
@ -469,6 +479,72 @@ func (p *EventParams) getStringReplacements(addObjectData bool) []string {
return replacements
}
func getFileContent(conn *BaseConnection, virtualPath string, expectedSize int) ([]byte, error) {
fs, fsPath, err := conn.GetFsAndResolvedPath(virtualPath)
if err != nil {
return nil, err
}
f, r, cancelFn, err := fs.Open(fsPath, 0)
if err != nil {
return nil, err
}
if cancelFn == nil {
cancelFn = func() {}
}
defer cancelFn()
var reader io.ReadCloser
if f != nil {
reader = f
} else {
reader = r
}
defer reader.Close()
data := make([]byte, expectedSize)
_, err = io.ReadFull(reader, data)
return data, err
}
func getMailAttachments(user dataprovider.User, attachments []string, replacer *strings.Replacer) ([]mail.File, error) {
var files []mail.File
user, err := getUserForEventAction(user)
if err != nil {
return nil, err
}
connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
err = user.CheckFsRoot(connectionID)
defer user.CloseFs() //nolint:errcheck
if err != nil {
return nil, err
}
conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
totalSize := int64(0)
for _, virtualPath := range attachments {
virtualPath = util.CleanPath(replaceWithReplacer(virtualPath, replacer))
info, err := conn.DoStat(virtualPath, 0, false)
if err != nil {
return nil, fmt.Errorf("unable to get info for file %q, user %q: %w", virtualPath, conn.User.Username, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("cannot attach non regular file %q", virtualPath)
}
totalSize += info.Size()
if totalSize > emailAttachmentsMaxSize {
return nil, fmt.Errorf("unable to send files as attachment, size too large: %s", util.ByteCountIEC(totalSize))
}
data, err := getFileContent(conn, virtualPath, int(info.Size()))
if err != nil {
return nil, fmt.Errorf("unable to get content for file %q, user %q: %w", virtualPath, conn.User.Username, err)
}
files = append(files, mail.File{
Name: path.Base(virtualPath),
Data: data,
})
}
return files, nil
}
func replaceWithReplacer(input string, replacer *strings.Replacer) string {
if !strings.Contains(input, "{{") {
return input
@ -622,10 +698,24 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params EventP
body := replaceWithReplacer(c.Body, replacer)
subject := replaceWithReplacer(c.Subject, replacer)
startTime := time.Now()
err := smtp.SendEmail(c.Recipients, subject, body, smtp.EmailContentTypeTextPlain)
var files []mail.File
if len(c.Attachments) > 0 {
user, err := params.getUserFromSender()
if err != nil {
return err
}
files, err = getMailAttachments(user, c.Attachments, replacer)
if err != nil {
return err
}
}
err := smtp.SendEmail(c.Recipients, subject, body, smtp.EmailContentTypeTextPlain, files...)
eventManagerLog(logger.LevelDebug, "executed email notification action, elapsed: %s, error: %v",
time.Since(startTime), err)
return err
if err != nil {
return fmt.Errorf("unable to send email: %w", err)
}
return nil
}
func getUserForEventAction(user dataprovider.User) (dataprovider.User, error) {
@ -1228,6 +1318,10 @@ func (j *eventCronJob) Run() {
eventManagerLog(logger.LevelError, "unable to load rule with name %q", j.ruleName)
return
}
if err = rule.CheckActionsConsistency(""); err != nil {
eventManagerLog(logger.LevelWarn, "scheduled rule %q skipped: %v", rule.Name, err)
return
}
task, err := j.getTask(rule)
if err != nil {
return

View file

@ -15,6 +15,7 @@
package common
import (
"crypto/rand"
"fmt"
"net/http"
"os"
@ -339,6 +340,14 @@ func TestEventManagerErrors(t *testing.T) {
},
})
assert.Error(t, err)
_, err = getMailAttachments(dataprovider.User{
Groups: []sdk.GroupMapping{
{
Name: groupName,
Type: sdk.GroupTypePrimary,
},
}}, []string{"/a", "/b"}, nil)
assert.Error(t, err)
dataRetentionAction := dataprovider.BaseEventAction{
Type: dataprovider.ActionTypeDataRetentionCheck,
@ -848,6 +857,68 @@ func TestEventRuleActions(t *testing.T) {
assert.NoError(t, err)
}
func TestGetFileContent(t *testing.T) {
username := "test_user_get_file_content"
user := dataprovider.User{
BaseUser: sdk.BaseUser{
Username: username,
Permissions: map[string][]string{
"/": {dataprovider.PermAny},
},
HomeDir: filepath.Join(os.TempDir(), username),
},
}
err := dataprovider.AddUser(&user, "", "")
assert.NoError(t, err)
err = os.MkdirAll(user.GetHomeDir(), os.ModePerm)
assert.NoError(t, err)
fileContent := []byte("test file content")
err = os.WriteFile(filepath.Join(user.GetHomeDir(), "file.txt"), fileContent, 0666)
assert.NoError(t, err)
replacer := strings.NewReplacer("old", "new")
files, err := getMailAttachments(user, []string{"/file.txt"}, replacer)
assert.NoError(t, err)
if assert.Len(t, files, 1) {
assert.Equal(t, fileContent, files[0].Data)
}
// missing file
_, err = getMailAttachments(user, []string{"/file1.txt"}, replacer)
assert.Error(t, err)
// directory
_, err = getMailAttachments(user, []string{"/"}, replacer)
assert.Error(t, err)
// files too large
content := make([]byte, emailAttachmentsMaxSize/2+1)
_, err = rand.Read(content)
assert.NoError(t, err)
err = os.WriteFile(filepath.Join(user.GetHomeDir(), "file1.txt"), content, 0666)
assert.NoError(t, err)
err = os.WriteFile(filepath.Join(user.GetHomeDir(), "file2.txt"), content, 0666)
assert.NoError(t, err)
files, err = getMailAttachments(user, []string{"/file1.txt"}, replacer)
assert.NoError(t, err)
if assert.Len(t, files, 1) {
assert.Equal(t, content, files[0].Data)
}
_, err = getMailAttachments(user, []string{"/file1.txt", "/file2.txt"}, replacer)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "size too large")
}
// change the filesystem provider
user.FsConfig.Provider = sdk.CryptedFilesystemProvider
user.FsConfig.CryptConfig.Passphrase = kms.NewPlainSecret("pwd")
err = dataprovider.UpdateUser(&user, "", "")
assert.NoError(t, err)
// the file is not encrypted so reading the encryption header will fail
_, err = getMailAttachments(user, []string{"/file.txt"}, replacer)
assert.Error(t, err)
err = dataprovider.DeleteUser(username, "", "")
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
func TestFilesystemActionErrors(t *testing.T) {
err := executeFsRuleAction(dataprovider.EventActionFilesystemConfig{}, dataprovider.ConditionOptions{}, EventParams{})
if assert.Error(t, err) {
@ -874,6 +945,15 @@ func TestFilesystemActionErrors(t *testing.T) {
},
},
}
err = executeEmailRuleAction(dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.net"},
Subject: "subject",
Body: "body",
Attachments: []string{"/file.txt"},
}, EventParams{
sender: username,
})
assert.Error(t, err)
conn := NewBaseConnection("", protocolEventAction, "", "", user)
err = executeDeleteFileFsAction(conn, "", nil)
assert.Error(t, err)
@ -888,6 +968,17 @@ func TestFilesystemActionErrors(t *testing.T) {
assert.Error(t, err)
err = executeExistFsActionForUser(nil, testReplacer, user)
assert.Error(t, err)
err = executeEmailRuleAction(dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.net"},
Subject: "subject",
Body: "body",
Attachments: []string{"/file1.txt"},
}, EventParams{
sender: username,
})
assert.Error(t, err)
_, err = getFileContent(NewBaseConnection("", protocolEventAction, "", "", user), "/f.txt", 1234)
assert.Error(t, err)
user.FsConfig.Provider = sdk.LocalFilesystemProvider
user.Permissions["/"] = []string{dataprovider.PermUpload}
@ -1130,6 +1221,19 @@ func TestScheduledActions(t *testing.T) {
job.Run()
assert.DirExists(t, backupsPath)
action.Type = dataprovider.ActionTypeEmail
action.Options = dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"example@example.com"},
Subject: "test with attachments",
Body: "body",
Attachments: []string{"/file1.txt"},
},
}
err = dataprovider.UpdateEventAction(action, "", "")
assert.NoError(t, err)
job.Run() // action is not compatible with a scheduled rule
err = dataprovider.DeleteEventRule(rule.Name, "", "")
assert.NoError(t, err)
err = dataprovider.DeleteEventAction(action.Name, "", "")

View file

@ -408,7 +408,6 @@ func TestChtimesOpenHandle(t *testing.T) {
sftpUser, _, err := httpdtest.AddUser(getTestSFTPUser(), http.StatusCreated)
assert.NoError(t, err)
u := getCryptFsUser()
u.Username += "_crypt"
cryptFsUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
@ -2326,7 +2325,6 @@ func TestCrossFolderRename(t *testing.T) {
assert.NoError(t, err, string(resp))
u := getCryptFsUser()
u.Username += "_crypt"
u.VirtualFolders = []vfs.VirtualFolder{
{
BaseVirtualFolder: vfs.BaseVirtualFolder{
@ -3764,6 +3762,99 @@ func TestEventRuleFsActions(t *testing.T) {
assert.NoError(t, err)
}
func TestEventActionEmailAttachments(t *testing.T) {
smtpCfg := smtp.Config{
Host: "127.0.0.1",
Port: 2525,
From: "notify@example.com",
TemplatesPath: "templates",
}
err := smtpCfg.Initialize(configDir)
require.NoError(t, err)
a1 := dataprovider.BaseEventAction{
Name: "action1",
Type: dataprovider.ActionTypeEmail,
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.com"},
Subject: `"{{Event}}" from "{{Name}}"`,
Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}",
Attachments: []string{"/{{VirtualPath}}"},
},
},
}
action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
assert.NoError(t, err)
r1 := dataprovider.EventRule{
Name: "test email with attachment",
Trigger: dataprovider.EventTriggerFsEvent,
Conditions: dataprovider.EventConditions{
FsEvents: []string{"upload"},
},
Actions: []dataprovider.EventAction{
{
BaseEventAction: dataprovider.BaseEventAction{
Name: action1.Name,
},
Order: 1,
},
},
}
rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
assert.NoError(t, err)
localUser, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
u := getTestSFTPUser()
u.FsConfig.SFTPConfig.BufferSize = 1
sftpUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
cryptFsUser, _, err := httpdtest.AddUser(getCryptFsUser(), http.StatusCreated)
assert.NoError(t, err)
for _, user := range []dataprovider.User{localUser, sftpUser, cryptFsUser} {
conn, client, err := getSftpClient(user)
if assert.NoError(t, err) {
defer conn.Close()
defer client.Close()
lastReceivedEmail.reset()
f, err := client.Create(testFileName)
assert.NoError(t, err)
_, err = f.Write(testFileContent)
assert.NoError(t, err)
err = f.Close()
assert.NoError(t, err)
assert.Eventually(t, func() bool {
return lastReceivedEmail.get().From != ""
}, 1500*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
assert.True(t, util.Contains(email.To, "test@example.com"))
assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: "upload" from "%s"`, user.Username))
assert.Contains(t, string(email.Data), "Content-Disposition: attachment")
}
}
_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(sftpUser, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(localUser, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(localUser.GetHomeDir())
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(cryptFsUser, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(cryptFsUser.GetHomeDir())
assert.NoError(t, err)
smtpCfg = smtp.Config{}
err = smtpCfg.Initialize(configDir)
require.NoError(t, err)
}
func TestEventRuleFirstUploadDownloadActions(t *testing.T) {
smtpCfg := smtp.Config{
Host: "127.0.0.1",
@ -5112,6 +5203,7 @@ func getTestSFTPUser() dataprovider.User {
func getCryptFsUser() dataprovider.User {
u := getTestUser()
u.Username += "_crypt"
u.FsConfig.Provider = sdk.CryptedFilesystemProvider
u.FsConfig.CryptConfig.Passphrase = kms.NewPlainSecret(defaultPassword)
return u

View file

@ -17,6 +17,7 @@ package dataprovider
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net/http"
"path"
@ -295,9 +296,10 @@ func (c *EventActionCommandConfig) validate() error {
// EventActionEmailConfig defines the configuration options for SMTP event actions
type EventActionEmailConfig struct {
Recipients []string `json:"recipients,omitempty"`
Subject string `json:"subject,omitempty"`
Body string `json:"body,omitempty"`
Recipients []string `json:"recipients,omitempty"`
Subject string `json:"subject,omitempty"`
Body string `json:"body,omitempty"`
Attachments []string `json:"attachments,omitempty"`
}
// GetRecipientsAsString returns the list of recipients as comma separated string
@ -305,6 +307,11 @@ func (c EventActionEmailConfig) GetRecipientsAsString() string {
return strings.Join(c.Recipients, ",")
}
// GetAttachmentsAsString returns the list of attachments as comma separated string
func (c EventActionEmailConfig) GetAttachmentsAsString() string {
return strings.Join(c.Attachments, ",")
}
func (c *EventActionEmailConfig) validate() error {
if len(c.Recipients) == 0 {
return util.NewValidationError("at least one email recipient is required")
@ -321,6 +328,14 @@ func (c *EventActionEmailConfig) validate() error {
if c.Body == "" {
return util.NewValidationError("email body is required")
}
for idx, val := range c.Attachments {
val = strings.TrimSpace(val)
if val == "" {
return util.NewValidationError("invalid path to attach")
}
c.Attachments[idx] = util.CleanPath(val)
}
c.Attachments = util.RemoveDuplicates(c.Attachments, false)
return nil
}
@ -549,6 +564,8 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
o.SetEmptySecretsIfNil()
emailRecipients := make([]string, len(o.EmailConfig.Recipients))
copy(emailRecipients, o.EmailConfig.Recipients)
emailAttachments := make([]string, len(o.EmailConfig.Attachments))
copy(emailAttachments, o.EmailConfig.Attachments)
folders := make([]FolderRetention, 0, len(o.RetentionConfig.Folders))
for _, folder := range o.RetentionConfig.Folders {
folders = append(folders, FolderRetention{
@ -577,9 +594,10 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
EnvVars: cloneKeyValues(o.CmdConfig.EnvVars),
},
EmailConfig: EventActionEmailConfig{
Recipients: emailRecipients,
Subject: o.EmailConfig.Subject,
Body: o.EmailConfig.Body,
Recipients: emailRecipients,
Subject: o.EmailConfig.Subject,
Body: o.EmailConfig.Body,
Attachments: emailAttachments,
},
RetentionConfig: EventActionDataRetentionConfig{
Folders: folders,
@ -1102,6 +1120,16 @@ func (r *EventRule) checkProviderEventActions(providerObjectType string) error {
return nil
}
func (r *EventRule) hasUserAssociated(providerObjectType string) bool {
switch r.Trigger {
case EventTriggerProviderEvent:
return providerObjectType == actionObjectUser
case EventTriggerFsEvent:
return true
}
return false
}
// CheckActionsConsistency returns an error if the actions cannot be executed
func (r *EventRule) CheckActionsConsistency(providerObjectType string) error {
switch r.Trigger {
@ -1122,6 +1150,13 @@ func (r *EventRule) CheckActionsConsistency(providerObjectType string) error {
return err
}
}
for _, action := range r.Actions {
if action.Type == ActionTypeEmail && len(action.BaseEventAction.Options.EmailConfig.Attachments) > 0 {
if !r.hasUserAssociated(providerObjectType) {
return errors.New("cannot send an email with attachments for a rule with no user associated")
}
}
}
return nil
}

View file

@ -1144,9 +1144,10 @@ func TestBasicActionRulesHandling(t *testing.T) {
a.Type = dataprovider.ActionTypeEmail
a.Options = dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"email@example.com"},
Subject: "Event: {{Event}}",
Body: "test mail body",
Recipients: []string{"email@example.com"},
Subject: "Event: {{Event}}",
Body: "test mail body",
Attachments: []string{"/{{VirtualPath}}"},
},
}
@ -18828,14 +18829,16 @@ func TestWebEventAction(t *testing.T) {
// change action type again
action.Type = dataprovider.ActionTypeEmail
action.Options.EmailConfig = dataprovider.EventActionEmailConfig{
Recipients: []string{"address1@example.com", "address2@example.com"},
Subject: "subject",
Body: "body",
Recipients: []string{"address1@example.com", "address2@example.com"},
Subject: "subject",
Body: "body",
Attachments: []string{"/file1.txt", "/file2.txt"},
}
form.Set("type", fmt.Sprintf("%d", action.Type))
form.Set("email_recipients", "address1@example.com, address2@example.com")
form.Set("email_subject", action.Options.EmailConfig.Subject)
form.Set("email_body", action.Options.EmailConfig.Body)
form.Set("email_attachments", "file1.txt, file2.txt")
req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
@ -18850,6 +18853,7 @@ func TestWebEventAction(t *testing.T) {
assert.Equal(t, action.Options.EmailConfig.Recipients, actionGet.Options.EmailConfig.Recipients)
assert.Equal(t, action.Options.EmailConfig.Subject, actionGet.Options.EmailConfig.Subject)
assert.Equal(t, action.Options.EmailConfig.Body, actionGet.Options.EmailConfig.Body)
assert.Equal(t, action.Options.EmailConfig.Attachments, actionGet.Options.EmailConfig.Attachments)
assert.Equal(t, dataprovider.EventActionHTTPConfig{}, actionGet.Options.HTTPConfig)
assert.Empty(t, actionGet.Options.CmdConfig.Cmd)
assert.Equal(t, 0, actionGet.Options.CmdConfig.Timeout)

View file

@ -1907,9 +1907,10 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
EnvVars: getKeyValsFromPostFields(r, "cmd_env_key", "cmd_env_val"),
},
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: strings.Split(strings.ReplaceAll(r.Form.Get("email_recipients"), " ", ""), ","),
Subject: r.Form.Get("email_subject"),
Body: r.Form.Get("email_body"),
Recipients: strings.Split(strings.ReplaceAll(r.Form.Get("email_recipients"), " ", ""), ","),
Subject: r.Form.Get("email_subject"),
Body: r.Form.Get("email_body"),
Attachments: strings.Split(strings.ReplaceAll(r.Form.Get("email_attachments"), " ", ""), ","),
},
RetentionConfig: dataprovider.EventActionDataRetentionConfig{
Folders: foldersRetention,

View file

@ -2244,6 +2244,14 @@ func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActi
if expected.Body != actual.Body {
return errors.New("email body mismatch")
}
if len(expected.Attachments) != len(actual.Attachments) {
return errors.New("email attachments mismatch")
}
for _, v := range expected.Attachments {
if !util.Contains(actual.Attachments, v) {
return errors.New("email attachments content mismatch")
}
}
return nil
}

View file

@ -183,7 +183,7 @@ func RenderPasswordResetTemplate(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) error {
func SendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...mail.File) error {
if smtpServer == nil {
return errors.New("smtp: not configured")
}
@ -207,6 +207,9 @@ func SendEmail(to []string, subject, body string, contentType EmailContentType)
default:
return fmt.Errorf("smtp: unsupported body content type %v", contentType)
}
for _, attachment := range attachments {
email.Attach(&attachment)
}
if email.Error != nil {
return fmt.Errorf("smtp: email error: %w", email.Error)
}

View file

@ -4978,7 +4978,7 @@ components:
description: |
Defines how to check if this config points to the same server as another config. If different configs point to the same server the renaming between the fs configs is allowed:
* `0` username and endpoint must match. This is the default
* `1` only the endpoint must match
* `1` only the endpoint must match
HTTPFsConfig:
type: object
properties:
@ -6071,6 +6071,11 @@ components:
type: string
body:
type: string
attachments:
type: array
items:
type: string
description: 'list of file paths to attach. The total size is limited to 10 MB'
EventActionDataRetentionConfig:
type: object
properties:

View file

@ -342,6 +342,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div class="form-group row action-type action-smtp">
<label for="idEmailAttachments" class="col-sm-2 col-form-label">Email 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>
<small id="smtpAttachmentsHelpBlock" class="form-text text-muted">
Comma separated paths to attach. Placeholders are supported. The total size is limited to 10 MB.
</small>
</div>
</div>
<div class="card bg-light mb-3 action-type action-dataretention">
<div class="card-header">
<b>Data retention</b>