From 987662a89ac6cd09f14a24cc5392cb568841f21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Men=C3=A9ndez?= Date: Wed, 25 Sep 2024 16:08:57 +0200 Subject: [PATCH] feature: generic smtp provider for email notifications (#18) replace sendgrid by generic smtp implementation for email notifications --- api/api_test.go | 14 ++-- api/users_test.go | 19 +++-- cmd/service/main.go | 50 +++++++----- example.env | 7 +- go.mod | 2 - go.sum | 4 - notifications/notifications.go | 18 ++++- notifications/sendgrid/email.go | 52 ------------- notifications/smtp/smtp.go | 133 ++++++++++++++++++++++++++++++++ notifications/smtp/test.go | 73 ++++++++++++++++++ notifications/testmail/mail.go | 97 ----------------------- notifications/twilio/sms.go | 16 +++- 12 files changed, 294 insertions(+), 191 deletions(-) delete mode 100644 notifications/sendgrid/email.go create mode 100644 notifications/smtp/smtp.go 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 dc55834..de6a57a 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -11,7 +11,7 @@ import ( "github.com/vocdoni/saas-backend/account" "github.com/vocdoni/saas-backend/api" "github.com/vocdoni/saas-backend/db" - "github.com/vocdoni/saas-backend/notifications/sendgrid" + "github.com/vocdoni/saas-backend/notifications/smtp" "github.com/vocdoni/saas-backend/notifications/twilio" "go.vocdoni.io/dvote/apiclient" "go.vocdoni.io/dvote/log" @@ -28,12 +28,15 @@ func main() { flag.StringP("vocdoniApi", "v", "https://api-dev.vocdoni.net/v2", "vocdoni node remote API URL") flag.StringP("privateKey", "k", "", "private key for the Vocdoni account") flag.BoolP("fullTransparentMode", "a", false, "allow all transactions and do not modify any of them") - flag.String("sendgridAPIKey", "", "SendGrid API key") - flag.String("sendgridFromAddress", "", "SendGrid from address") - flag.String("sendgridFromName", "Vocdoni", "SendGrid from name") + flag.String("smtpServer", "", "SMTP server") + flag.Int("smtpPort", 587, "SMTP port") + flag.String("smtpUsername", "", "SMTP username") + flag.String("smtpPassword", "", "SMTP password") + flag.String("emailFromAddress", "", "Email service from address") + flag.String("emailFromName", "Vocdoni", "Email service from name") flag.String("twilioAccountSid", "", "Twilio account SID") flag.String("twilioAuthToken", "", "Twilio auth token") - flag.String("twilioFromNumber", "", "Twilio from number") + flag.String("smsFromNumber", "", "SMS from number") // parse flags flag.Parse() // initialize Viper @@ -52,10 +55,13 @@ func main() { } mongoURL := viper.GetString("mongoURL") mongoDB := viper.GetString("mongoDB") - // mail vars - sendgridAPIKey := viper.GetString("sendgridAPIKey") - sendgridFromAddress := viper.GetString("sendgridFromAddress") - sendgridFromName := viper.GetString("sendgridFromName") + // email vars + smtpServer := viper.GetString("smtpServer") + smtpPort := viper.GetInt("smtpPort") + smtpUsername := viper.GetString("smtpUsername") + smtpPassword := viper.GetString("smtpPassword") + emailFromAddress := viper.GetString("emailFromAddress") + emailFromName := viper.GetString("emailFromName") // sms vars twilioAccountSid := viper.GetString("twilioAccountSid") twilioAuthToken := viper.GetString("twilioAuthToken") @@ -93,24 +99,30 @@ func main() { Account: acc, FullTransparentMode: fullTransparentMode, } - // create email notifications service if the required parameters are set and - // include it in the API configuration - if sendgridAPIKey != "" && sendgridFromAddress != "" && sendgridFromName != "" { - apiConf.MailService = new(sendgrid.SendGridEmail) - if err := apiConf.MailService.Init(&sendgrid.SendGridConfig{ - FromName: sendgridFromName, - FromAddress: sendgridFromAddress, - APIKey: sendgridAPIKey, + // overwrite the email notifications service with the SMTP service if the + // required parameters are set and include it in the API configuration + if smtpServer != "" && smtpUsername != "" && smtpPassword != "" { + if emailFromAddress == "" || emailFromName == "" { + log.Fatal("emailFromAddress and emailFromName are required") + } + apiConf.MailService = new(smtp.SMTPEmail) + if err := apiConf.MailService.New(&smtp.SMTPConfig{ + FromName: emailFromName, + FromAddress: emailFromAddress, + SMTPServer: smtpServer, + SMTPPort: smtpPort, + SMTPUsername: smtpUsername, + SMTPPassword: smtpPassword, }); err != nil { log.Fatalf("could not create the email service: %v", err) } - log.Infow("email service created", "from", fmt.Sprintf("%s <%s>", sendgridFromName, sendgridFromAddress)) + log.Infow("email service created", "from", fmt.Sprintf("%s <%s>", emailFromName, emailFromAddress)) } // create SMS notifications service if the required parameters are set and // 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/example.env b/example.env index ccaa452..0cf14f3 100644 --- a/example.env +++ b/example.env @@ -1,5 +1,8 @@ VOCDONI_PORT=8080 VOCDONI_SECRET=supersecret VOCDONI_PRIVATEKEY=vochain-private-key -VOCDONI_SENDGRIDAPIKEY=SG.1234567890 -VOCDONI_SENDGRIDFROMADDRESS=admin@email.com \ No newline at end of file +VOCDONI_SMTPSERVER=smpt.server.com +VOCDONI_SMTPUSERNAME=admin +VOCDONI_SMTPPASSWORD=password +VOCDONI_EMAILFROMADDRESS=admin@email.com +VOCDONI_EMAILFROMADDRESS=admin@email.com \ No newline at end of file diff --git a/go.mod b/go.mod index 74b45cc..0638a53 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/go-chi/cors v1.2.1 github.com/go-chi/jwtauth/v5 v5.3.1 github.com/lestrrat-go/jwx/v2 v2.0.20 - github.com/sendgrid/sendgrid-go v3.16.0+incompatible github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 github.com/testcontainers/testcontainers-go v0.32.0 @@ -297,7 +296,6 @@ require ( github.com/samber/lo v1.39.0 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/segmentio/asm v1.2.0 // indirect - github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/sethvargo/go-retry v0.2.4 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shirou/gopsutil/v3 v3.23.12 // indirect diff --git a/go.sum b/go.sum index a9b2b71..9eda011 100644 --- a/go.sum +++ b/go.sum @@ -1379,10 +1379,6 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/kafka-go v0.1.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= github.com/segmentio/kafka-go v0.2.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= -github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= -github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= -github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw= -github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= 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/sendgrid/email.go b/notifications/sendgrid/email.go deleted file mode 100644 index 83a932a..0000000 --- a/notifications/sendgrid/email.go +++ /dev/null @@ -1,52 +0,0 @@ -package sendgrid - -import ( - "context" - "fmt" - - "github.com/sendgrid/sendgrid-go" - "github.com/sendgrid/sendgrid-go/helpers/mail" - "github.com/vocdoni/saas-backend/notifications" -) - -type SendGridConfig struct { - FromName string - FromAddress string - APIKey string -} - -type SendGridEmail struct { - config *SendGridConfig - client *sendgrid.Client -} - -func (sg *SendGridEmail) Init(rawConfig any) error { - // parse configuration - config, ok := rawConfig.(*SendGridConfig) - if !ok { - return fmt.Errorf("invalid SendGrid configuration") - } - // set configuration in struct - sg.config = config - // init SendGrid client - sg.client = sendgrid.NewSendClient(sg.config.APIKey) - return nil -} - -func (sg *SendGridEmail) SendNotification(ctx context.Context, notification *notifications.Notification) error { - // create from and to email - from := mail.NewEmail(sg.config.FromName, sg.config.FromAddress) - to := mail.NewEmail(notification.ToName, notification.ToAddress) - // create email with notification data - message := mail.NewSingleEmail(from, notification.Subject, to, notification.Body, notification.Body) - // send the email - res, err := sg.client.SendWithContext(ctx, message) - if err != nil { - return fmt.Errorf("could not send email: %v", err) - } - // check the response status code, it should be 2xx - if res.StatusCode/100 != 2 { - return fmt.Errorf("could not send email: %v", res.Body) - } - return nil -} diff --git a/notifications/smtp/smtp.go b/notifications/smtp/smtp.go new file mode 100644 index 0000000..46d0498 --- /dev/null +++ b/notifications/smtp/smtp.go @@ -0,0 +1,133 @@ +package smtp + +import ( + "bytes" + "context" + "fmt" + "mime/multipart" + "net/mail" + "net/smtp" + "net/textproto" + + "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 + 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 +} + +// 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 { + return fmt.Errorf("invalid SMTP configuration") + } + // parse from email + if _, err := mail.ParseAddress(config.FromAddress); err != nil { + return fmt.Errorf("could not parse from email: %v", err) + } + // set configuration in struct + se.config = config + // init SMTP auth + 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) + if err != nil { + return fmt.Errorf("could not compose email body: %v", err) + } + // send the email + server := fmt.Sprintf("%s:%d", se.config.SMTPServer, se.config.SMTPPort) + // create a channel to handle errors + errCh := make(chan error, 1) + go func() { + // send the message + err := smtp.SendMail(server, se.auth, se.config.FromAddress, []string{notification.ToAddress}, body) + errCh <- err + close(errCh) + }() + // wait for the message to be sent or the context to be done + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-errCh: + return err + } +} + +// 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) + if err != nil { + return nil, fmt.Errorf("could not parse to email: %v", err) + } + // create email headers + var headers bytes.Buffer + boundary := "----=_Part_0_123456789.123456789" + headers.WriteString(fmt.Sprintf("From: %s\r\n", se.config.FromAddress)) + headers.WriteString(fmt.Sprintf("To: %s\r\n", to.String())) + headers.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject)) + headers.WriteString("MIME-Version: 1.0\r\n") + headers.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) + headers.WriteString("\r\n") // blank line between headers and body + // create multipart writer + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.SetBoundary(boundary); err != nil { + return nil, fmt.Errorf("could not set boundary: %v", err) + } + // TODO: plain text part + // textPart, _ := writer.CreatePart(textproto.MIMEHeader{ + // "Content-Type": {"text/plain; charset=\"UTF-8\""}, + // "Content-Transfer-Encoding": {"7bit"}, + // }) + // textPart.Write([]byte(notification.PlainBody)) + // HTML part + htmlPart, _ := writer.CreatePart(textproto.MIMEHeader{ + "Content-Type": {"text/html; charset=\"UTF-8\""}, + "Content-Transfer-Encoding": {"7bit"}, + }) + if _, err := htmlPart.Write([]byte(notification.Body)); err != nil { + return nil, fmt.Errorf("could not write HTML part: %v", err) + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("could not close writer: %v", err) + } + // combine headers and body and return the content + var email bytes.Buffer + email.Write(headers.Bytes()) + email.Write(body.Bytes()) + return email.Bytes(), nil +} 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{}