eventmanager: allow to add attachments to email actions
Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
parent
3e8254e398
commit
6777008aec
13 changed files with 386 additions and 28 deletions
|
@ -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
2
go.mod
|
@ -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
4
go.sum
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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, "", "")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue