From 715917ca53a3f7510eaa5bc818c405f70c626cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Men=C3=A9ndez?= Date: Wed, 25 Sep 2024 09:02:12 +0200 Subject: [PATCH] including comments, mergin testing email service with new smtp service and improve testing --- api/api_test.go | 14 ++--- api/users_test.go | 19 ++++--- cmd/service/main.go | 4 +- notifications/notifications.go | 18 ++++++- notifications/smtp/smtp.go | 27 ++++++++-- notifications/smtp/test.go | 73 +++++++++++++++++++++++++ notifications/testmail/mail.go | 97 ---------------------------------- notifications/twilio/sms.go | 16 +++++- 8 files changed, 150 insertions(+), 118 deletions(-) create mode 100644 notifications/smtp/test.go delete mode 100644 notifications/testmail/mail.go diff --git a/api/api_test.go b/api/api_test.go index 6af731f..a227759 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -11,7 +11,7 @@ import ( "github.com/vocdoni/saas-backend/account" "github.com/vocdoni/saas-backend/db" - "github.com/vocdoni/saas-backend/notifications/testmail" + "github.com/vocdoni/saas-backend/notifications/smtp" "github.com/vocdoni/saas-backend/test" "go.vocdoni.io/dvote/apiclient" ) @@ -44,7 +44,7 @@ var testDB *db.MongoStorage // testMailService is the test mail service for the tests. Make it global so it // can be accessed by the tests directly. -var testMailService *testmail.TestMail +var testMailService *smtp.SMTPEmail // testURL helper function returns the full URL for the given path using the // test host and port. @@ -161,14 +161,14 @@ func TestMain(m *testing.M) { panic(err) } // create test mail service - testMailService = new(testmail.TestMail) - if err := testMailService.Init(&testmail.TestMailConfig{ + testMailService = new(smtp.SMTPEmail) + if err := testMailService.New(&smtp.SMTPConfig{ FromAddress: adminEmail, - SMTPUser: adminUser, + SMTPUsername: adminUser, SMTPPassword: adminPass, - Host: mailHost, + SMTPServer: mailHost, SMTPPort: smtpPort.Int(), - APIPort: apiPort.Int(), + TestAPIPort: apiPort.Int(), }); err != nil { panic(err) } diff --git a/api/users_test.go b/api/users_test.go index ed65c37..ef824b3 100644 --- a/api/users_test.go +++ b/api/users_test.go @@ -3,8 +3,10 @@ package api import ( "bytes" "context" + "fmt" "io" "net/http" + "regexp" "strings" "testing" @@ -179,11 +181,13 @@ func TestVerifyAccountHandler(t *testing.T) { // get the verification code from the email mailBody, err := testMailService.FindEmail(context.Background(), testEmail) c.Assert(err, qt.IsNil) - mailCode := strings.TrimPrefix(mailBody, VerificationCodeTextBody) + // create a regex to find the verification code in the email + mailCodeRgx := regexp.MustCompile(fmt.Sprintf(`%s(.{%d})`, VerificationCodeTextBody, VerificationCodeLength*2)) + mailCode := mailCodeRgx.FindStringSubmatch(mailBody) // verify the user verification := mustMarshal(&UserVerification{ Email: testEmail, - Code: mailCode, + Code: mailCode[1], }) req, err = http.NewRequest(http.MethodPost, testURL(verifyUserEndpoint), bytes.NewBuffer(verification)) c.Assert(err, qt.IsNil) @@ -240,11 +244,14 @@ func TestRecoverAndResetPassword(t *testing.T) { // get the verification code from the email mailBody, err := testMailService.FindEmail(context.Background(), testEmail) c.Assert(err, qt.IsNil) - verifyMailCode := strings.TrimPrefix(mailBody, VerificationCodeTextBody) + // create a regex to find the verification code in the email + mailCodeRgx := regexp.MustCompile(fmt.Sprintf(`%s(.{%d})`, VerificationCodeTextBody, VerificationCodeLength*2)) + verifyMailCode := mailCodeRgx.FindStringSubmatch(mailBody) + c.Log(verifyMailCode[1]) // verify the user verification := mustMarshal(&UserVerification{ Email: testEmail, - Code: verifyMailCode, + Code: verifyMailCode[1], }) req, err = http.NewRequest(http.MethodPost, testURL(verifyUserEndpoint), bytes.NewBuffer(verification)) c.Assert(err, qt.IsNil) @@ -262,12 +269,12 @@ func TestRecoverAndResetPassword(t *testing.T) { // get the recovery code from the email mailBody, err = testMailService.FindEmail(context.Background(), testEmail) c.Assert(err, qt.IsNil) - passResetMailCode := strings.TrimPrefix(mailBody, VerificationCodeTextBody) + passResetMailCode := mailCodeRgx.FindStringSubmatch(mailBody) // reset the password newPassword := "password2" resetPass := mustMarshal(&UserPasswordReset{ Email: testEmail, - Code: passResetMailCode, + Code: passResetMailCode[1], NewPassword: newPassword, }) req, err = http.NewRequest(http.MethodPost, testURL(usersResetPasswordEndpoint), bytes.NewBuffer(resetPass)) diff --git a/cmd/service/main.go b/cmd/service/main.go index 0186cd4..de6a57a 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -106,7 +106,7 @@ func main() { log.Fatal("emailFromAddress and emailFromName are required") } apiConf.MailService = new(smtp.SMTPEmail) - if err := apiConf.MailService.Init(&smtp.SMTPConfig{ + if err := apiConf.MailService.New(&smtp.SMTPConfig{ FromName: emailFromName, FromAddress: emailFromAddress, SMTPServer: smtpServer, @@ -122,7 +122,7 @@ func main() { // include it in the API configuration if twilioAccountSid != "" && twilioAuthToken != "" && twilioFromNumber != "" { apiConf.SMSService = new(twilio.TwilioSMS) - if err := apiConf.SMSService.Init(&twilio.TwilioConfig{ + if err := apiConf.SMSService.New(&twilio.TwilioConfig{ AccountSid: twilioAccountSid, AuthToken: twilioAuthToken, FromNumber: twilioFromNumber, diff --git a/notifications/notifications.go b/notifications/notifications.go index 430d7e7..ded00c5 100644 --- a/notifications/notifications.go +++ b/notifications/notifications.go @@ -2,6 +2,10 @@ package notifications import "context" +// Notification represents a notification to be sent, it can be an email or an +// SMS. It contains the recipient's name, address, number, the subject and the +// body of the message. The recipient's name and address are used for emails, +// while the recipient's number is used for SMS. type Notification struct { ToName string ToAddress string @@ -10,7 +14,19 @@ type Notification struct { Body string } +// NotificationService is the interface that must be implemented by any +// notification service. It contains the methods New and SendNotification. +// Init is used to initialize the service with the configuration, and +// SendNotification is used to send a notification. type NotificationService interface { - Init(conf any) error + // New initializes the notification service with the configuration. Each + // service implementation can have its own configuration type, which is + // passed as an argument to this method and must be casted to the correct + // type inside the method. + New(conf any) error + // SendNotification sends a notification to the recipient. The notification + // contains the recipient's name, address, number, the subject and the body + // of the message. This method cannot be blocking, so it must return an + // error if the notification could not be sent or if the context is done. SendNotification(context.Context, *Notification) error } diff --git a/notifications/smtp/smtp.go b/notifications/smtp/smtp.go index e101ab4..46d0498 100644 --- a/notifications/smtp/smtp.go +++ b/notifications/smtp/smtp.go @@ -12,21 +12,33 @@ import ( "github.com/vocdoni/saas-backend/notifications" ) +// SMTPConfig represents the configuration for the SMTP email service. It +// contains the sender's name, address, SMTP username, password, server and +// port. The TestAPIPort is used to define the port of the API service used +// for testing the email service locally to check messages (for example using +// MailHog). type SMTPConfig struct { FromName string FromAddress string - SMTPServer string - SMTPPort int SMTPUsername string SMTPPassword string + SMTPServer string + SMTPPort int + TestAPIPort int } +// SMTPEmail is the implementation of the NotificationService interface for the +// SMTP email service. It contains the configuration and the SMTP auth. It uses +// the net/smtp package to send emails. type SMTPEmail struct { config *SMTPConfig auth smtp.Auth } -func (se *SMTPEmail) Init(rawConfig any) error { +// New initializes the SMTP email service with the configuration. It sets the +// SMTP auth if the username and password are provided. It returns an error if +// the configuration is invalid or if the from email could not be parsed. +func (se *SMTPEmail) New(rawConfig any) error { // parse configuration config, ok := rawConfig.(*SMTPConfig) if !ok { @@ -39,10 +51,14 @@ func (se *SMTPEmail) Init(rawConfig any) error { // set configuration in struct se.config = config // init SMTP auth - se.auth = smtp.PlainAuth("", se.config.SMTPUsername, se.config.SMTPPassword, se.config.SMTPServer) + if se.config.SMTPUsername == "" || se.config.SMTPPassword == "" { + se.auth = smtp.PlainAuth("", se.config.SMTPUsername, se.config.SMTPPassword, se.config.SMTPServer) + } return nil } +// SendNotification sends an email notification to the recipient. It composes +// the email body with the notification data and sends it using the SMTP server. func (se *SMTPEmail) SendNotification(ctx context.Context, notification *notifications.Notification) error { // compose email body body, err := se.composeBody(notification) @@ -68,6 +84,9 @@ func (se *SMTPEmail) SendNotification(ctx context.Context, notification *notific } } +// composeBody creates the email body with the notification data. It creates a +// multipart email with a plain text and an HTML part. It returns the email +// content as a byte slice or an error if the body could not be composed. func (se *SMTPEmail) composeBody(notification *notifications.Notification) ([]byte, error) { // parse 'to' email to, err := mail.ParseAddress(notification.ToAddress) diff --git a/notifications/smtp/test.go b/notifications/smtp/test.go new file mode 100644 index 0000000..35fe2a2 --- /dev/null +++ b/notifications/smtp/test.go @@ -0,0 +1,73 @@ +package smtp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const ( + searchInboxTestEndpoint = "http://%s:%d/api/v2/search?kind=to&query=%s" + clearInboxTestEndpoint = "http://%s:%d/api/v1/messages" +) + +// FindEmail searches for an email in the test API service. It sends a GET +// request to the search endpoint with the recipient's email address as a query +// parameter. If the email is found, it returns the email body and clears the +// inbox. If the email is not found, it returns an EOF error. If the request +// fails, it returns an error with the status code. This method is used for +// testing the email service. +func (sm *SMTPEmail) FindEmail(ctx context.Context, to string) (string, error) { + searchEndpoint := fmt.Sprintf(searchInboxTestEndpoint, sm.config.SMTPServer, sm.config.TestAPIPort, to) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchEndpoint, nil) + if err != nil { + return "", fmt.Errorf("could not create request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("could not send request: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + type mailResponse struct { + Items []struct { + Content struct { + Body string `json:"Body"` + } `json:"Content"` + } `json:"items"` + } + mailResults := mailResponse{} + if err := json.NewDecoder(resp.Body).Decode(&mailResults); err != nil { + return "", fmt.Errorf("could not decode response: %v", err) + } + if len(mailResults.Items) == 0 { + return "", io.EOF + } + return mailResults.Items[0].Content.Body, sm.clear() +} + +func (sm *SMTPEmail) clear() error { + clearEndpoint := fmt.Sprintf(clearInboxTestEndpoint, sm.config.SMTPServer, sm.config.TestAPIPort) + req, err := http.NewRequest(http.MethodDelete, clearEndpoint, nil) + if err != nil { + return fmt.Errorf("could not create request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("could not send request: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return nil +} diff --git a/notifications/testmail/mail.go b/notifications/testmail/mail.go deleted file mode 100644 index 13b1e4b..0000000 --- a/notifications/testmail/mail.go +++ /dev/null @@ -1,97 +0,0 @@ -package testmail - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/smtp" - - "github.com/vocdoni/saas-backend/notifications" -) - -type TestMailConfig struct { - FromAddress string - SMTPUser string - SMTPPassword string - Host string - SMTPPort int - APIPort int -} - -type TestMail struct { - config *TestMailConfig -} - -func (tm *TestMail) Init(rawConfig any) error { - config, ok := rawConfig.(*TestMailConfig) - if !ok { - return fmt.Errorf("invalid TestMail configuration") - } - tm.config = config - return nil -} - -func (tm *TestMail) SendNotification(_ context.Context, notification *notifications.Notification) error { - auth := smtp.PlainAuth("", tm.config.SMTPUser, tm.config.SMTPPassword, tm.config.Host) - smtpAddr := fmt.Sprintf("%s:%d", tm.config.Host, tm.config.SMTPPort) - msg := []byte("To: " + notification.ToAddress + "\r\n" + - "Subject: " + notification.Subject + "\r\n" + - "\r\n" + - notification.Body + "\r\n") - return smtp.SendMail(smtpAddr, auth, tm.config.FromAddress, []string{notification.ToAddress}, msg) -} - -func (tm *TestMail) clear() error { - clearEndpoint := fmt.Sprintf("http://%s:%d/api/v1/messages", tm.config.Host, tm.config.APIPort) - req, err := http.NewRequest("DELETE", clearEndpoint, nil) - if err != nil { - return fmt.Errorf("could not create request: %v", err) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("could not send request: %v", err) - } - defer func() { - _ = resp.Body.Close() - }() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - return nil -} - -func (tm *TestMail) FindEmail(ctx context.Context, to string) (string, error) { - searchEndpoint := fmt.Sprintf("http://%s:%d/api/v2/search?kind=to&query=%s", tm.config.Host, tm.config.APIPort, to) - req, err := http.NewRequestWithContext(ctx, "GET", searchEndpoint, nil) - if err != nil { - return "", fmt.Errorf("could not create request: %v", err) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("could not send request: %v", err) - } - defer func() { - _ = resp.Body.Close() - }() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - type mailResponse struct { - Items []struct { - Content struct { - Body string `json:"Body"` - } `json:"Content"` - } `json:"items"` - } - mailResults := mailResponse{} - if err := json.NewDecoder(resp.Body).Decode(&mailResults); err != nil { - return "", fmt.Errorf("could not decode response: %v", err) - } - if len(mailResults.Items) == 0 { - return "", io.EOF - } - return mailResults.Items[0].Content.Body, tm.clear() -} diff --git a/notifications/twilio/sms.go b/notifications/twilio/sms.go index d88ae84..cbb482b 100644 --- a/notifications/twilio/sms.go +++ b/notifications/twilio/sms.go @@ -15,18 +15,28 @@ const ( AuthTokenEnv = "TWILIO_AUTH_TOKEN" ) +// TwilioConfig represents the configuration for the Twilio SMS service. It +// contains the account SID, the auth token and the number from which the SMS +// will be sent. type TwilioConfig struct { AccountSid string AuthToken string FromNumber string } +// TwilioSMS is the implementation of the NotificationService interface for the +// Twilio SMS service. It contains the configuration and the Twilio REST client. type TwilioSMS struct { config *TwilioConfig client *t.RestClient } -func (tsms *TwilioSMS) Init(rawConfig any) error { +// New initializes the Twilio SMS service with the configuration. It sets the +// account SID and the auth token as environment variables and initializes the +// Twilio REST client. It returns an error if the configuration is invalid or if +// the environment variables could not be set. +// Read more here: https://www.twilio.com/docs/messaging/quickstart/go +func (tsms *TwilioSMS) New(rawConfig any) error { // parse configuration config, ok := rawConfig.(*TwilioConfig) if !ok { @@ -46,6 +56,10 @@ func (tsms *TwilioSMS) Init(rawConfig any) error { return nil } +// SendNotification sends an SMS notification to the recipient. It creates a +// message with the configured sender number and the notification data. It +// returns an error if the notification could not be sent or if the context is +// done. func (tsms *TwilioSMS) SendNotification(ctx context.Context, notification *notifications.Notification) error { // create message with configured sender number and notification data params := &api.CreateMessageParams{}