Skip to content

Commit

Permalink
including comments, mergin testing email service with new smtp servic…
Browse files Browse the repository at this point in the history
…e and improve testing
  • Loading branch information
lucasmenendez committed Sep 25, 2024
1 parent 2718ec7 commit 715917c
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 118 deletions.
14 changes: 7 additions & 7 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
19 changes: 13 additions & 6 deletions api/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package api
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"testing"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions cmd/service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion notifications/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
27 changes: 23 additions & 4 deletions notifications/smtp/smtp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions notifications/smtp/test.go
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 0 additions & 97 deletions notifications/testmail/mail.go

This file was deleted.

Loading

0 comments on commit 715917c

Please sign in to comment.