diff --git a/emailsender/cmd/app/main.go b/emailsender/cmd/app/main.go index 0ddd52ac76..726b78fabd 100644 --- a/emailsender/cmd/app/main.go +++ b/emailsender/cmd/app/main.go @@ -16,6 +16,7 @@ import ( "github.com/golang/glog" "github.com/stackrox/acs-fleet-manager/emailsender/config" "github.com/stackrox/acs-fleet-manager/emailsender/pkg/api" + "github.com/stackrox/acs-fleet-manager/emailsender/pkg/email" "github.com/stackrox/acs-fleet-manager/emailsender/pkg/metrics" ) @@ -39,9 +40,19 @@ func main() { ctx := context.Background() + // initialize components + sesClient, err := email.NewSES(ctx) + if err != nil { + glog.Errorf("Failed to initialise SES Client: %v", err) + os.Exit(1) + } + temporarySenderName := "noreply@mail.acs.rhcloud.com" + emailSender := email.NewEmailSender(temporarySenderName, sesClient) + emailHandler := api.NewEmailHandler(emailSender) + // base router router := mux.NewRouter() - api.SetupRoutes(router) + api.SetupRoutes(router, emailHandler) server := http.Server{Addr: cfg.ServerAddress, Handler: router} diff --git a/emailsender/pkg/api/emailhandler.go b/emailsender/pkg/api/emailhandler.go new file mode 100644 index 0000000000..739bf9173d --- /dev/null +++ b/emailsender/pkg/api/emailhandler.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "fmt" + "github.com/golang/glog" + "github.com/stackrox/acs-fleet-manager/emailsender/pkg/email" + "net/http" +) + +type EmailHandler struct { + emailSender email.Sender +} + +// SendEmailRequest represents API requests for sending email +type SendEmailRequest struct { + To []string + RawMessage []byte +} + +type Envelope map[string]interface{} + +func NewEmailHandler(emailSender email.Sender) *EmailHandler { + return &EmailHandler{ + emailSender: emailSender, + } +} + +func (eh *EmailHandler) SendEmail(w http.ResponseWriter, r *http.Request) { + var request SendEmailRequest + + jsonDecoder := json.NewDecoder(r.Body) + jsonDecoder.DisallowUnknownFields() + + if err := jsonDecoder.Decode(&request); err != nil { + eh.errorResponse(w, "Cannot decode send email request payload", http.StatusBadRequest) + return + } + + if err := eh.emailSender.Send(r.Context(), request.To, request.RawMessage); err != nil { + eh.errorResponse(w, "Cannot send email", http.StatusInternalServerError) + return + } + + envelope := Envelope{ + "status": "sent", + } + if err := eh.jsonResponse(w, envelope, http.StatusOK); err != nil { + glog.Errorf("Failed creating json response: %v", err) + http.Error(w, "Cannot create json response", http.StatusInternalServerError) + } +} + +func (eh *EmailHandler) jsonResponse(w http.ResponseWriter, envelop Envelope, statusCode int) error { + j, err := json.Marshal(envelop) + if err != nil { + return fmt.Errorf("failed to marshal: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + _, err = w.Write(j) + if err != nil { + return fmt.Errorf("failed to write json response: %v", err) + } + + return nil +} + +func (eh *EmailHandler) errorResponse(w http.ResponseWriter, message string, statusCode int) { + envelope := Envelope{ + "error": message, + } + + if err := eh.jsonResponse(w, envelope, statusCode); err != nil { + glog.Errorf("Failed creating error json response: %v", err) + http.Error(w, "Can not create error json response", http.StatusInternalServerError) + } +} diff --git a/emailsender/pkg/api/emailhandler_test.go b/emailsender/pkg/api/emailhandler_test.go new file mode 100644 index 0000000000..5faee7dae2 --- /dev/null +++ b/emailsender/pkg/api/emailhandler_test.go @@ -0,0 +1,96 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/stackrox/acs-fleet-manager/emailsender/pkg/email" + "net/http" + "net/http/httptest" + "testing" +) + +type MockEmailSender struct { + SendFunc func(ctx context.Context, to []string, rawMessage []byte) error +} + +func (m *MockEmailSender) Send(ctx context.Context, to []string, rawMessage []byte) error { + return m.SendFunc(ctx, to, rawMessage) +} + +var simpleEmailSender = &MockEmailSender{ + SendFunc: func(ctx context.Context, to []string, rawMessage []byte) error { + return nil + }, +} + +func TestEmailHandler_SendEmail(t *testing.T) { + subject := "Test subject" + textBody := "text body" + var messageBuf bytes.Buffer + messageBuf.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + messageBuf.WriteString(textBody) + rawMessage := messageBuf.Bytes() + + sendEmailRequest := SendEmailRequest{ + To: []string{"to1@example.com", "to2@example.com"}, + RawMessage: rawMessage, + } + jsonReq, _ := json.Marshal(sendEmailRequest) + invalidJsonReq, _ := json.Marshal(map[string]string{ + "invalid": "JSON", + }) + + tests := []struct { + name string + emailSender email.Sender + req *http.Request + wantCode int + wantBody string + }{ + { + name: "should return JSON response with StatusOK to a valid email request", + emailSender: simpleEmailSender, + req: httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(jsonReq)), + wantCode: http.StatusOK, + wantBody: `{"status":"sent"}`, + }, + { + name: "should return JSON error with StatusBadRequest when cannot decode request", + emailSender: simpleEmailSender, + req: httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(invalidJsonReq)), + wantCode: http.StatusBadRequest, + wantBody: `{"error":"Cannot decode send email request payload"}`, + }, + { + name: "should return JSON error with StatusInternalServerError when cannot send email", + emailSender: &MockEmailSender{ + SendFunc: func(ctx context.Context, to []string, rawMessage []byte) error { + return errors.New("failed to send email") + }, + }, + req: httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(jsonReq)), + wantCode: http.StatusInternalServerError, + wantBody: `{"error":"Cannot send email"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eh := &EmailHandler{ + emailSender: tt.emailSender, + } + resp := httptest.NewRecorder() + eh.SendEmail(resp, tt.req) + + if resp.Result().StatusCode != tt.wantCode { + t.Errorf("expected status code %d, got %d", tt.wantCode, resp.Result().StatusCode) + } + + if resp.Body.String() != tt.wantBody { + t.Errorf("expected body %s, got %s", tt.wantBody, resp.Body.String()) + } + }) + } +} diff --git a/emailsender/pkg/api/handlers.go b/emailsender/pkg/api/handlers.go index 581fcc585f..aad9d72c81 100644 --- a/emailsender/pkg/api/handlers.go +++ b/emailsender/pkg/api/handlers.go @@ -2,28 +2,9 @@ package api import ( - "encoding/json" "net/http" ) -// EmailRequest represents API requests for sending email -type EmailRequest struct { - To []string - RawMessage []byte -} - -// SendEmailHandler handles sending email API endpoint -func SendEmailHandler(w http.ResponseWriter, r *http.Request) { - var email EmailRequest - - if err := json.NewDecoder(r.Body).Decode(&email); err != nil { - http.Error(w, "Can not decode request payload", http.StatusBadRequest) - return - } - - w.WriteHeader(http.StatusOK) -} - // HealthCheckHandler returns 200 HTTP status code func HealthCheckHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/emailsender/pkg/api/routes.go b/emailsender/pkg/api/routes.go index 6e18f67b48..ca81710062 100644 --- a/emailsender/pkg/api/routes.go +++ b/emailsender/pkg/api/routes.go @@ -2,15 +2,14 @@ package api import ( "github.com/gorilla/mux" - loggingMiddleware "github.com/stackrox/acs-fleet-manager/pkg/server/logging" ) // SetupRoutes configures API route mapping -func SetupRoutes(router *mux.Router) { +func SetupRoutes(router *mux.Router, emailHandler *EmailHandler) { // add middlewares router.Use(loggingMiddleware.RequestLoggingMiddleware, EnsureJSONContentType) router.HandleFunc("/health", HealthCheckHandler).Methods("GET") - router.HandleFunc("/api/v1/acscsemail", SendEmailHandler).Methods("POST") + router.HandleFunc("/api/v1/acscsemail", emailHandler.SendEmail).Methods("POST") } diff --git a/emailsender/pkg/email/sender.go b/emailsender/pkg/email/sender.go new file mode 100644 index 0000000000..95e87e4d21 --- /dev/null +++ b/emailsender/pkg/email/sender.go @@ -0,0 +1,33 @@ +package email + +import ( + "context" + "fmt" + "github.com/golang/glog" +) + +type Sender interface { + Send(ctx context.Context, to []string, rawMessage []byte) error +} + +type MailSender struct { + from string + ses *SES +} + +func NewEmailSender(from string, ses *SES) *MailSender { + return &MailSender{ + from: from, + ses: ses, + } +} + +func (s *MailSender) Send(ctx context.Context, to []string, rawMessage []byte) error { + _, err := s.ses.SendRawEmail(ctx, s.from, to, rawMessage) + if err != nil { + glog.Errorf("Failed sending email: %v", err) + return fmt.Errorf("failed to send email: %v", err) + } + + return nil +} diff --git a/emailsender/pkg/email/sender_test.go b/emailsender/pkg/email/sender_test.go new file mode 100644 index 0000000000..e5ff9286ef --- /dev/null +++ b/emailsender/pkg/email/sender_test.go @@ -0,0 +1,43 @@ +package email + +import ( + "bytes" + "context" + "fmt" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/ses" +) + +func TestSend_Success(t *testing.T) { + from := "sender@example.com" + to := []string{"to1@example.com", "to2@example.com"} + subject := "Test subject" + textBody := "text body" + var messageBuf bytes.Buffer + messageBuf.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + messageBuf.WriteString(textBody) + rawMessage := messageBuf.Bytes() + called := false + + mockClient := &MockSESClient{ + SendRawEmailFunc: func(ctx context.Context, params *ses.SendRawEmailInput, optFns ...func(*ses.Options)) (*ses.SendRawEmailOutput, error) { + called = true + return &ses.SendRawEmailOutput{ + MessageId: aws.String("test-message-id"), + }, nil + }, + } + mockedSES := &SES{sesClient: mockClient} + mockedSender := MailSender{ + from, + mockedSES, + } + + err := mockedSender.Send(context.Background(), to, rawMessage) + + assert.NoError(t, err) + assert.True(t, called) +}