From ef7c97154e4d9f34fa3964220a42f69753199909 Mon Sep 17 00:00:00 2001 From: Daniel Liszka Date: Tue, 13 Jun 2023 16:13:30 +0200 Subject: [PATCH 1/5] feat: Added SMTP extension, refs #158 Signed-off-by: Daniel Liszka --- .../extensions/core/smtp/v1/README.md | 20 ++ .../extensions/core/smtp/v1/extension.go | 230 ++++++++++++++++++ app/controlplane/extensions/extensions.go | 2 + 3 files changed, 252 insertions(+) create mode 100644 app/controlplane/extensions/core/smtp/v1/README.md create mode 100644 app/controlplane/extensions/core/smtp/v1/extension.go diff --git a/app/controlplane/extensions/core/smtp/v1/README.md b/app/controlplane/extensions/core/smtp/v1/README.md new file mode 100644 index 000000000..d4d042570 --- /dev/null +++ b/app/controlplane/extensions/core/smtp/v1/README.md @@ -0,0 +1,20 @@ +# SMTP Fan-out Extension + +With this extension, you can send an email for every workflow run and attestation, ensuring better communication. + +## How to use it + +In the following example, we will use the AWS SES service. + +1. To get started, you need to register the extension in your Chainloop organization. +``` +chainloop integration add smtp --options user=AHDHSYEE7e73,password=kjsdfda8asd****,host=email-smtp.us-east-1.amazonaws.com,port=587,to=platform-team@example.com,from=notifier@example.com +``` + +2. When attaching the integration to your workflow, you have the option to specify CC: + +``` +chainloop workflow integration attach --workflow $WID --integration $IID --options cc=security@example.com +``` + +Starting now, every time a workflow run occurs, an email notification will be sent containing the details of the run and attestation. \ No newline at end of file diff --git a/app/controlplane/extensions/core/smtp/v1/extension.go b/app/controlplane/extensions/core/smtp/v1/extension.go new file mode 100644 index 000000000..017658cfe --- /dev/null +++ b/app/controlplane/extensions/core/smtp/v1/extension.go @@ -0,0 +1,230 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package smtp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + nsmtp "net/smtp" + + "github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1" + "github.com/go-kratos/kratos/v2/log" + "github.com/in-toto/in-toto-golang/in_toto" +) + +type Integration struct { + *sdk.FanOutIntegration +} + +type registrationRequest struct { + To string `json:"to" jsonschema:"required,format=email"` + From string `json:"from" jsonschema:"required,format=email"` + User string `json:"user" jsonschema:"required,minLength=1"` + Password string `json:"password" jsonschema:"required"` + Host string `json:"host" jsonschema:"required,format="` + Port string `json:"port" jsonschema:"required"` +} + +type registrationState struct { + To string `json:"to"` + From string `json:"from"` + User string `json:"user"` + Host string `json:"host"` + Port string `json:"port"` +} + +type attachmentRequest struct { + Cc string `json:"cc" jsonschema:"format=email"` +} + +type attachmentState struct { + Cc string `json:"cc"` +} + +func New(l log.Logger) (sdk.FanOut, error) { + base, err := sdk.NewFanOut( + &sdk.NewParams{ + ID: "smtp", + Version: "0.1", + Logger: l, + InputSchema: &sdk.InputSchema{ + Registration: registrationRequest{}, + Attachment: attachmentRequest{}, + }, + }, + sdk.WithEnvelope(), + ) + + if err != nil { + return nil, err + } + + return &Integration{base}, nil +} + +// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization +func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) { + i.Logger.Info("registration requested") + + // Unmarshal the request + var request *registrationRequest + if err := sdk.FromConfig(req.Payload, &request); err != nil { + return nil, fmt.Errorf("invalid registration request: %w", err) + } + + response := &sdk.RegistrationResponse{} + + to, from, user, password, host, port := request.To, request.From, request.User, request.Password, request.Host, request.Port + rawConfig, err := sdk.ToConfig(®istrationState{To: to, From: from, User: user, Host: host, Port: port}) + if err != nil { + return nil, fmt.Errorf("marshalling configuration: %w", err) + } + response.Configuration = rawConfig + response.Credentials = &sdk.Credentials{Password: password} + + // validate and notify + subject := "[chainloop] New SMTP integration added!" + tpl := ` + We successfully registered a new SMTP integration in your Chainloop organization. + + Extension: %s version: %s + - Host: %s + - Port: %s + - User: %s + - From: %s + - To: %s + ` + body := fmt.Sprintf(tpl, i.Describe().ID, i.Describe().Version, host, port, user, from, to) + sendEmail(host, port, user, password, from, to, "", subject, body) + + return response, nil +} + +// Attachment is executed when to attach a registered instance of this integration to a specific workflow +func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) { + i.Logger.Info("attachment requested") + + // Parse the request that has already been validated against the input schema + var request *attachmentRequest + if err := sdk.FromConfig(req.Payload, &request); err != nil { + return nil, fmt.Errorf("invalid attachment request: %w", err) + } + + var rc *registrationState + if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &rc); err != nil { + return nil, errors.New("invalid registration configuration") + } + + response := &sdk.AttachmentResponse{} + rawConfig, err := sdk.ToConfig(&attachmentState{Cc: request.Cc}) + if err != nil { + return nil, fmt.Errorf("marshalling configuration: %w", err) + } + response.Configuration = rawConfig + + return response, nil +} + +// Send the SBOM to the configured Dependency Track instance +func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error { + i.Logger.Info("execution requested") + + if err := validateExecuteRequest(req); err != nil { + return fmt.Errorf("running validation for workflow id %s: %w", req.WorkflowID, err) + } + + var rc *registrationState + if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &rc); err != nil { + return errors.New("invalid registration configuration") + } + + var ac *attachmentState + if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &ac); err != nil { + return errors.New("invalid attachment configuration") + } + + // get the attestation + decodedPayload, err := req.Input.DSSEnvelope.DecodeB64Payload() + if err != nil { + return err + } + statement := &in_toto.Statement{} + if err := json.Unmarshal(decodedPayload, statement); err != nil { + return fmt.Errorf("un-marshaling predicate: %w", err) + } + jsonBytes, err := json.MarshalIndent(statement, "", " ") + i.Logger.Info("statement", string(jsonBytes)) + if err != nil { + fmt.Println("Error marshaling JSON:", err) + return err + } + + // send the email + to, from, user, password, host, port := rc.To, rc.From, rc.User, req.RegistrationInfo.Credentials.Password, rc.Host, rc.Port + subject := "[chainloop] New workflow run finished successfully!" + tpl := `A new workflow run finished successfully! + +# Workflow: %s + +# in-toto statement: + %s + +This email has been delivered via integration %s version %s. + ` + body := fmt.Sprintf(tpl, req.WorkflowID, jsonBytes, i.Describe().ID, i.Describe().Version) + sendEmail(host, port, user, password, from, to, ac.Cc, subject, body) + + return nil +} + +func validateExecuteRequest(req *sdk.ExecutionRequest) error { + if req == nil || req.Input == nil || req.Input.DSSEnvelope == nil { + return errors.New("invalid input") + } + + if req.RegistrationInfo == nil || req.RegistrationInfo.Configuration == nil { + return errors.New("missing registration configuration") + } + + if req.RegistrationInfo.Credentials == nil { + return errors.New("missing credentials") + } + + if req.AttachmentInfo == nil || req.AttachmentInfo.Configuration == nil { + return errors.New("missing attachment configuration") + } + + return nil +} + +func sendEmail(host string, port string, user, password, from, to, cc, subject, body string) error { + message := "From: " + from + "\n" + + "To: " + to + "\n" + + "Cc: " + cc + "\n" + + "Subject: " + subject + "\n\n" + + body + + auth := nsmtp.PlainAuth("", user, password, host) + err := nsmtp.SendMail(host+":"+port, auth, from, []string{to}, []byte(message)) + if err != nil { + fmt.Println("Error sending email1:", err) + return err + } + + return nil +} diff --git a/app/controlplane/extensions/extensions.go b/app/controlplane/extensions/extensions.go index 90697481b..cc4e2b166 100644 --- a/app/controlplane/extensions/extensions.go +++ b/app/controlplane/extensions/extensions.go @@ -17,6 +17,7 @@ package extensions import ( "github.com/chainloop-dev/chainloop/app/controlplane/extensions/core/dependencytrack/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/extensions/core/smtp/v1" "github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1" "github.com/chainloop-dev/chainloop/internal/servicelogger" "github.com/go-kratos/kratos/v2/log" @@ -32,6 +33,7 @@ func Load(l log.Logger) (sdk.AvailableExtensions, error) { // Eventually this will be dynamically loaded from a directory toEnable := []sdk.FanOutFactory{ dependencytrack.New, + smtp.New, } // Initialize and load the extensions From bd59a7d67740a691f9ac64a21504f2af102e6c21 Mon Sep 17 00:00:00 2001 From: Daniel Liszka Date: Tue, 13 Jun 2023 16:32:22 +0200 Subject: [PATCH 2/5] Handle sendEmail erorr propoerly Signed-off-by: Daniel Liszka --- app/controlplane/extensions/core/smtp/v1/extension.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/controlplane/extensions/core/smtp/v1/extension.go b/app/controlplane/extensions/core/smtp/v1/extension.go index 017658cfe..40ae0be2b 100644 --- a/app/controlplane/extensions/core/smtp/v1/extension.go +++ b/app/controlplane/extensions/core/smtp/v1/extension.go @@ -110,7 +110,10 @@ func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) - To: %s ` body := fmt.Sprintf(tpl, i.Describe().ID, i.Describe().Version, host, port, user, from, to) - sendEmail(host, port, user, password, from, to, "", subject, body) + err = sendEmail(host, port, user, password, from, to, "", subject, body) + if err != nil { + return nil, fmt.Errorf("sending an email: %w", err) + } return response, nil } @@ -187,7 +190,10 @@ func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) erro This email has been delivered via integration %s version %s. ` body := fmt.Sprintf(tpl, req.WorkflowID, jsonBytes, i.Describe().ID, i.Describe().Version) - sendEmail(host, port, user, password, from, to, ac.Cc, subject, body) + err = sendEmail(host, port, user, password, from, to, ac.Cc, subject, body) + if err != nil { + return fmt.Errorf("sending an email: %w", err) + } return nil } From 703aeea914e304e958da0bcf620ea6a0eedc03f2 Mon Sep 17 00:00:00 2001 From: Daniel Liszka Date: Tue, 13 Jun 2023 18:22:12 +0200 Subject: [PATCH 3/5] Adding a test Signed-off-by: Daniel Liszka --- .../extensions/core/smtp/v1/extension_test.go | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 app/controlplane/extensions/core/smtp/v1/extension_test.go diff --git a/app/controlplane/extensions/core/smtp/v1/extension_test.go b/app/controlplane/extensions/core/smtp/v1/extension_test.go new file mode 100644 index 000000000..2f6f53386 --- /dev/null +++ b/app/controlplane/extensions/core/smtp/v1/extension_test.go @@ -0,0 +1,101 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package smtp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateRegistrationInput(t *testing.T) { + testCases := []struct { + name string + input map[string]interface{} + errMsg string + }{ + { + name: "missing properties", + input: map[string]interface{}{}, + errMsg: "missing properties: 'to', 'from', 'user'", + }, + { + name: "valid request", + input: map[string]interface{}{"from": "test@example.com", "to": "test@example.com", "host": "localhost", "port": "25", "user": "test", "password": "test"}, + }, + } + + integration, err := New(nil) + require.NoError(t, err) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload, err := json.Marshal(tc.input) + require.NoError(t, err) + err = integration.ValidateRegistrationRequest(payload) + if tc.errMsg != "" { + assert.ErrorContains(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateAttachmentInput(t *testing.T) { + testCases := []struct { + name string + input map[string]interface{} + errMsg string + }{ + { + name: "valid with no optional cc", + input: map[string]interface{}{}, + }, + { + name: "valid cc format", + input: map[string]interface{}{"cc": "test@example.com"}, + }, + { + name: "invalid cc format", + input: map[string]interface{}{"cc": "testexample.com"}, + errMsg: "is not valid 'email'", + }, + } + + integration, err := New(nil) + require.NoError(t, err) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload, err := json.Marshal(tc.input) + require.NoError(t, err) + err = integration.ValidateAttachmentRequest(payload) + if tc.errMsg != "" { + assert.ErrorContains(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNewIntegration(t *testing.T) { + _, err := New(nil) + assert.NoError(t, err) +} From 159c3d3c75b003807810b2c617f5988deab53a85 Mon Sep 17 00:00:00 2001 From: Daniel Liszka Date: Tue, 13 Jun 2023 18:22:37 +0200 Subject: [PATCH 4/5] Adding descriptions Signed-off-by: Daniel Liszka --- .../extensions/core/smtp/v1/extension.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/controlplane/extensions/core/smtp/v1/extension.go b/app/controlplane/extensions/core/smtp/v1/extension.go index 40ae0be2b..7a13822e7 100644 --- a/app/controlplane/extensions/core/smtp/v1/extension.go +++ b/app/controlplane/extensions/core/smtp/v1/extension.go @@ -32,12 +32,12 @@ type Integration struct { } type registrationRequest struct { - To string `json:"to" jsonschema:"required,format=email"` - From string `json:"from" jsonschema:"required,format=email"` - User string `json:"user" jsonschema:"required,minLength=1"` - Password string `json:"password" jsonschema:"required"` - Host string `json:"host" jsonschema:"required,format="` - Port string `json:"port" jsonschema:"required"` + To string `json:"to" jsonschema:"format=email,description=The email address to send the email to."` + From string `json:"from" jsonschema:"format=email,description=The email address of the sender."` + User string `json:"user" jsonschema:"minLength=1,description=The username to use for the SMTP authentication."` + Password string `json:"password" jsonschema:"description=The password to use for the SMTP authentication."` + Host string `json:"host" jsonschema:"description=The host to use for the SMTP authentication."` + Port string `json:"port" jsonschema:"description=The port to use for the SMTP authentication"` } type registrationState struct { @@ -49,7 +49,7 @@ type registrationState struct { } type attachmentRequest struct { - Cc string `json:"cc" jsonschema:"format=email"` + Cc string `json:"cc,omitempty" jsonschema:"format=email,description=The email address of the carbon copy recipient."` } type attachmentState struct { From b6533569384ae612b766818530f4fe5fa77967f3 Mon Sep 17 00:00:00 2001 From: Daniel Liszka Date: Wed, 14 Jun 2023 00:06:31 +0200 Subject: [PATCH 5/5] Applying some feedback from PR review Signed-off-by: Daniel Liszka --- .../extensions/core/smtp/v1/README.md | 10 ++++- .../extensions/core/smtp/v1/extension.go | 43 ++++++++----------- .../extensions/core/smtp/v1/extension_test.go | 7 ++- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/app/controlplane/extensions/core/smtp/v1/README.md b/app/controlplane/extensions/core/smtp/v1/README.md index d4d042570..caf66ea3c 100644 --- a/app/controlplane/extensions/core/smtp/v1/README.md +++ b/app/controlplane/extensions/core/smtp/v1/README.md @@ -1,6 +1,6 @@ # SMTP Fan-out Extension -With this extension, you can send an email for every workflow run and attestation, ensuring better communication. +With this extension, you can send an email for every workflow run and attestation. ## How to use it @@ -8,7 +8,7 @@ In the following example, we will use the AWS SES service. 1. To get started, you need to register the extension in your Chainloop organization. ``` -chainloop integration add smtp --options user=AHDHSYEE7e73,password=kjsdfda8asd****,host=email-smtp.us-east-1.amazonaws.com,port=587,to=platform-team@example.com,from=notifier@example.com +chainloop integration registered add smtp --options user=AHDHSYEE7e73,password=kjsdfda8asd****,host=email-smtp.us-east-1.amazonaws.com,port=587,to=platform-team@example.com,from=notifier@example.com ``` 2. When attaching the integration to your workflow, you have the option to specify CC: @@ -17,4 +17,10 @@ chainloop integration add smtp --options user=AHDHSYEE7e73,password=kjsdfda8asd* chainloop workflow integration attach --workflow $WID --integration $IID --options cc=security@example.com ``` +cc is optional: + +``` +chainloop workflow integration attach --workflow $WID --integration $IID +``` + Starting now, every time a workflow run occurs, an email notification will be sent containing the details of the run and attestation. \ No newline at end of file diff --git a/app/controlplane/extensions/core/smtp/v1/extension.go b/app/controlplane/extensions/core/smtp/v1/extension.go index 7a13822e7..2bda34ba6 100644 --- a/app/controlplane/extensions/core/smtp/v1/extension.go +++ b/app/controlplane/extensions/core/smtp/v1/extension.go @@ -37,7 +37,8 @@ type registrationRequest struct { User string `json:"user" jsonschema:"minLength=1,description=The username to use for the SMTP authentication."` Password string `json:"password" jsonschema:"description=The password to use for the SMTP authentication."` Host string `json:"host" jsonschema:"description=The host to use for the SMTP authentication."` - Port string `json:"port" jsonschema:"description=The port to use for the SMTP authentication"` + // TODO: Make the port an integer + Port string `json:"port" jsonschema:"description=The port to use for the SMTP authentication"` } type registrationState struct { @@ -49,11 +50,11 @@ type registrationState struct { } type attachmentRequest struct { - Cc string `json:"cc,omitempty" jsonschema:"format=email,description=The email address of the carbon copy recipient."` + CC string `json:"cc,omitempty" jsonschema:"format=email,description=The email address of the carbon copy recipient."` } type attachmentState struct { - Cc string `json:"cc"` + CC string `json:"cc"` } func New(l log.Logger) (sdk.FanOut, error) { @@ -87,15 +88,7 @@ func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) return nil, fmt.Errorf("invalid registration request: %w", err) } - response := &sdk.RegistrationResponse{} - to, from, user, password, host, port := request.To, request.From, request.User, request.Password, request.Host, request.Port - rawConfig, err := sdk.ToConfig(®istrationState{To: to, From: from, User: user, Host: host, Port: port}) - if err != nil { - return nil, fmt.Errorf("marshalling configuration: %w", err) - } - response.Configuration = rawConfig - response.Credentials = &sdk.Credentials{Password: password} // validate and notify subject := "[chainloop] New SMTP integration added!" @@ -110,11 +103,19 @@ func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) - To: %s ` body := fmt.Sprintf(tpl, i.Describe().ID, i.Describe().Version, host, port, user, from, to) - err = sendEmail(host, port, user, password, from, to, "", subject, body) + err := sendEmail(host, port, user, password, from, to, "", subject, body) if err != nil { return nil, fmt.Errorf("sending an email: %w", err) } + response := &sdk.RegistrationResponse{} + rawConfig, err := sdk.ToConfig(®istrationState{To: to, From: from, User: user, Host: host, Port: port}) + if err != nil { + return nil, fmt.Errorf("marshalling configuration: %w", err) + } + response.Configuration = rawConfig + response.Credentials = &sdk.Credentials{Password: password} + return response, nil } @@ -128,13 +129,8 @@ func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sd return nil, fmt.Errorf("invalid attachment request: %w", err) } - var rc *registrationState - if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &rc); err != nil { - return nil, errors.New("invalid registration configuration") - } - response := &sdk.AttachmentResponse{} - rawConfig, err := sdk.ToConfig(&attachmentState{Cc: request.Cc}) + rawConfig, err := sdk.ToConfig(&attachmentState{CC: request.CC}) if err != nil { return nil, fmt.Errorf("marshalling configuration: %w", err) } @@ -171,10 +167,8 @@ func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) erro return fmt.Errorf("un-marshaling predicate: %w", err) } jsonBytes, err := json.MarshalIndent(statement, "", " ") - i.Logger.Info("statement", string(jsonBytes)) if err != nil { - fmt.Println("Error marshaling JSON:", err) - return err + return fmt.Errorf("error marshaling JSON: %w", err) } // send the email @@ -190,7 +184,7 @@ func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) erro This email has been delivered via integration %s version %s. ` body := fmt.Sprintf(tpl, req.WorkflowID, jsonBytes, i.Describe().ID, i.Describe().Version) - err = sendEmail(host, port, user, password, from, to, ac.Cc, subject, body) + err = sendEmail(host, port, user, password, from, to, ac.CC, subject, body) if err != nil { return fmt.Errorf("sending an email: %w", err) } @@ -221,15 +215,14 @@ func validateExecuteRequest(req *sdk.ExecutionRequest) error { func sendEmail(host string, port string, user, password, from, to, cc, subject, body string) error { message := "From: " + from + "\n" + "To: " + to + "\n" + - "Cc: " + cc + "\n" + + "CC: " + cc + "\n" + "Subject: " + subject + "\n\n" + body auth := nsmtp.PlainAuth("", user, password, host) err := nsmtp.SendMail(host+":"+port, auth, from, []string{to}, []byte(message)) if err != nil { - fmt.Println("Error sending email1:", err) - return err + return fmt.Errorf("error sending email: %w", err) } return nil diff --git a/app/controlplane/extensions/core/smtp/v1/extension_test.go b/app/controlplane/extensions/core/smtp/v1/extension_test.go index 2f6f53386..7b613139e 100644 --- a/app/controlplane/extensions/core/smtp/v1/extension_test.go +++ b/app/controlplane/extensions/core/smtp/v1/extension_test.go @@ -36,7 +36,12 @@ func TestValidateRegistrationInput(t *testing.T) { }, { name: "valid request", - input: map[string]interface{}{"from": "test@example.com", "to": "test@example.com", "host": "localhost", "port": "25", "user": "test", "password": "test"}, + input: map[string]interface{}{"from": "test@example.com", "to": "test@example.com", "host": "smtp.service.example.com", "port": "25", "user": "test", "password": "test"}, + }, + { + name: "invalid email", + input: map[string]interface{}{"from": "testexample.com", "to": "test@example.com", "host": "smtp.service.example.com", "port": "25", "user": "test", "password": "test"}, + errMsg: "is not valid 'email'", }, }