Skip to content

Commit

Permalink
new internal struct to abstract api notifications (with html template…
Browse files Browse the repository at this point in the history
…s and links URI), tests updated
  • Loading branch information
lucasmenendez committed Nov 21, 2024
1 parent 16e6bf1 commit 4130ff1
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 122 deletions.
42 changes: 0 additions & 42 deletions api/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package api

import (
"time"

"github.com/vocdoni/saas-backend/notifications"
)

// VerificationCodeExpiration is the duration of the verification code
Expand All @@ -13,46 +11,6 @@ var VerificationCodeExpiration = 3 * time.Minute
const (
// VerificationCodeLength is the length of the verification code in bytes
VerificationCodeLength = 3
// VerificationCodeEmailSubject is the subject of the verification code email
VerificationCodeEmailSubject = "Vocdoni verification code"
// VerificationCodeTextBody is the body of the verification code email
VerificationCodeTextBody = `Your Vocdoni verification code is: %s
You can also use this link to verify your account: %s`
// verificationURI is the URI to verify the user account in the web app that
// must be included in the verification email.
VerificationURI = "/account/verify"
// WelcomeTemplate is the key that identifies the wellcome email template.
// It must be also the name of the file in the email templates directory.
WelcomeTemplate notifications.MailTemplate = "welcome"
// VerificationAccountTemplate is the key that identifies the verification
// account email template. It must be also the name of the file in the
// email templates directory.
VerificationAccountTemplate notifications.MailTemplate = "verification_account"
// PasswordResetEmailSubject is the subject of the password reset email.
PasswordResetEmailSubject = "Vocdoni password reset"
// PasswordResetTextBody is the body of the password reset email
PasswordResetTextBody = `Your Vocdoni password reset code is: %s
You can also use this link to reset your password: %s`
// PasswordResetURI is the URI to reset the user password in the web app
// that must be included in the password reset email.
PasswordResetURI = "/account/password/reset"
// PasswordResetTemplate is the key that identifies the password reset email
// template. It must be also the name of the file in the email templates
// directory.
PasswordResetTemplate notifications.MailTemplate = "forgot_password"
// InviteAdminTemplate is the key that identifies the invitation email
// template for the organization admin. It must be also the name of the file
// in the email templates directory.
InviteAdminTemplate notifications.MailTemplate = "invite_admin"
// InvitationEmailSubject is the subject of the invitation email
InvitationEmailSubject = "Vocdoni organization invitation"
// InvitationTextBody is the body of the invitation email
InvitationTextBody = `You code to join to '%s' organization is: %s
You can also use this link to join the organization: %s`
InvitationURI = "/account/invite"
// InvitationExpiration is the duration of the invitation code before it is invalidated
InvitationExpiration = 5 * 24 * time.Hour // 5 days
)
34 changes: 0 additions & 34 deletions api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,9 @@ import (
"github.com/go-chi/chi/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/vocdoni/saas-backend/db"
"github.com/vocdoni/saas-backend/notifications"
"go.vocdoni.io/dvote/log"
)

// sendNotification method sends a notification to the email provided. It
// requires the email, the name of the recipient, the subject, the plain body,
// the mail template and the data to fill the template. It returns an error if
// the mail service is available and the notification could not be sent. If the
// mail service is not available, the notification is not sent but the function
// returns nil.
func (a *API) sendNotification(ctx context.Context, email, name, subject,
plainbody string, temp notifications.MailTemplate, data any,
) error {
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
// send the verification code via email if the mail service is available
if a.mail != nil {
// create the notification with the verification code
notification := &notifications.Notification{
ToName: name,
ToAddress: email,
Subject: subject,
PlainBody: plainbody,
Body: plainbody,
}
// execute the template with the data provided
if err := notification.ExecTemplate(a.mailTemplates[temp], data); err != nil {
return err
}
// send the notification
if err := a.mail.SendNotification(ctx, notification); err != nil {
return err
}
}
return nil
}

// organizationFromRequest helper function allows to get the organization info
// related to the request provided. It gets the organization address from the
// URL parameters and retrieves the organization from the database. If the
Expand Down
90 changes: 90 additions & 0 deletions api/notifications.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package api

import (
"context"
"fmt"
"time"

"github.com/vocdoni/saas-backend/internal"
"github.com/vocdoni/saas-backend/notifications"
)

// apiNotification is an internal struct that represents a notification to be
// sent via notifications package. It contains the mail template, the link path
// and the notification to be sent.
type apiNotification struct {
Template notifications.MailTemplate
LinkPath string
Notification notifications.Notification
}

// VerifyAccountNotification is the notification to be sent when a user creates
// an account and needs to verify it.
var VerifyAccountNotification = apiNotification{
Template: "verification_account",
LinkPath: "/account/verify",
Notification: notifications.Notification{
Subject: "Vocdoni verification code",
PlainBody: `Your Vocdoni password reset code is: {{.Code}}
You can also use this link to reset your password: {{.Link}}`,
},
}

// PasswordResetNotification is the notification to be sent when a user requests
// a password reset.
var PasswordResetNotification = apiNotification{
Template: "forgot_password",
LinkPath: "/account/password/reset",
Notification: notifications.Notification{
Subject: "Vocdoni password reset",
PlainBody: `Your Vocdoni password reset code is: {{.Code}}
You can also use this link to reset your password: {{.Link}}`,
},
}

// InviteAdminNotification is the notification to be sent when a user is invited
// to be an admin of an organization.
var InviteAdminNotification = apiNotification{
Template: "invite_admin",
LinkPath: "/account/invite",
Notification: notifications.Notification{
Subject: "Vocdoni organization invitation",
PlainBody: `You code to join to '{{.Organization}}' organization is: {{.Code}}
You can also use this link to join the organization: {{.Link}}`,
},
}

// sendNotification method sends a notification to the email provided. It
// requires the email the API notification definition and the data to fill it.
// It clones the notification included in the API notification and fills it
// with the recipient email address and the data provided, using the template
// defined in the API notification. It returns an error if the mail service is
// available and the notification could not be sent. If the mail service is not
// available, the notification is not sent but the function returns nil.
func (a *API) sendNotification(ctx context.Context, to string, an apiNotification, data any) error {
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
// send the verification code via email if the mail service is available
if a.mail != nil {
// check if the email address is valid
if !internal.ValidEmail(to) {
return fmt.Errorf("invalid email address")
}
// clone the notification and create a pointer to it
notification := &an.Notification
// set the recipient email address
notification.ToAddress = to
// execute the template with the data provided
if err := notification.ExecTemplate(a.mailTemplates[an.Template], data); err != nil {
return err
}
// send the notification
if err := a.mail.SendNotification(ctx, notification); err != nil {
return err
}
}
return nil
}
17 changes: 7 additions & 10 deletions api/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package api

import (
"encoding/json"
"fmt"
"net/http"
"time"

Expand Down Expand Up @@ -298,20 +297,18 @@ func (a *API) inviteOrganizationMemberHandler(w http.ResponseWriter, r *http.Req
return
}
// send the invitation verification code to the user email
inviteLink, err := a.buildWebAppURL(InvitationURI, map[string]any{"email": invite.Email, "code": inviteCode, "address": org.Address})
inviteLink, err := a.buildWebAppURL(InviteAdminNotification.LinkPath,
map[string]any{"email": invite.Email, "code": inviteCode, "address": org.Address})
if err != nil {
log.Warnw("could not build verification link", "error", err)
ErrGenericInternalServerError.Write(w)
return
}
plainBody := fmt.Sprintf(InvitationTextBody, org.Address, inviteCode, inviteLink)
if err := a.sendNotification(r.Context(), invite.Email, invite.Email,
InvitationEmailSubject, plainBody, InviteAdminTemplate, struct {
Organization string
Code string
Link string
}{org.Address, inviteCode, inviteLink},
); err != nil {
if err := a.sendNotification(r.Context(), invite.Email, InviteAdminNotification, struct {
Organization string
Code string
Link string
}{org.Address, inviteCode, inviteLink}); err != nil {
log.Warnw("could not send verification code", "error", err)
ErrGenericInternalServerError.Write(w)
return
Expand Down
28 changes: 12 additions & 16 deletions api/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package api

import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
Expand Down Expand Up @@ -99,16 +98,15 @@ func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) {
return
}
// send the new verification code to the user email
userName := fmt.Sprintf("%s %s", userInfo.FirstName, userInfo.LastName)
verificationLink, err := a.buildWebAppURL(VerificationURI, map[string]any{"email": newUser.Email, "code": code})
verificationLink, err := a.buildWebAppURL(VerifyAccountNotification.LinkPath,
map[string]any{"email": newUser.Email, "code": code})
if err != nil {
log.Warnw("could not build verification link", "error", err)
ErrGenericInternalServerError.Write(w)
return
}
plainBody := fmt.Sprintf(VerificationCodeTextBody, code, verificationLink)
if err := a.sendNotification(r.Context(), userInfo.Email, userName,
VerificationCodeEmailSubject, plainBody, VerificationAccountTemplate, struct {
if err := a.sendNotification(r.Context(), userInfo.Email, VerifyAccountNotification,
struct {
Code string
Link string
}{code, verificationLink},
Expand Down Expand Up @@ -298,16 +296,15 @@ func (a *API) resendUserVerificationCodeHandler(w http.ResponseWriter, r *http.R
return
}
// send the new verification code to the user email
userName := fmt.Sprintf("%s %s", user.FirstName, user.LastName)
verificationLink, err := a.buildWebAppURL(VerificationURI, map[string]any{"email": user.Email, "code": newCode})
verificationLink, err := a.buildWebAppURL(VerifyAccountNotification.LinkPath,
map[string]any{"email": user.Email, "code": newCode})
if err != nil {
log.Warnw("could not build verification link", "error", err)
ErrGenericInternalServerError.Write(w)
return
}
plainBody := fmt.Sprintf(VerificationCodeTextBody, newCode, verificationLink)
if err := a.sendNotification(r.Context(), user.Email, userName, VerificationCodeEmailSubject,
plainBody, VerificationAccountTemplate, struct {
if err := a.sendNotification(r.Context(), user.Email, VerifyAccountNotification,
struct {
Code string
Link string
}{newCode, verificationLink},
Expand Down Expand Up @@ -495,16 +492,15 @@ func (a *API) recoverUserPasswordHandler(w http.ResponseWriter, r *http.Request)
return
}
// send the password reset code to the user email
userName := fmt.Sprintf("%s %s", user.FirstName, user.LastName)
resetLink, err := a.buildWebAppURL(PasswordResetURI, map[string]any{"email": user.Email, "code": code})
resetLink, err := a.buildWebAppURL(PasswordResetNotification.LinkPath,
map[string]any{"email": user.Email, "code": code})
if err != nil {
log.Warnw("could not build verification link", "error", err)
ErrGenericInternalServerError.Write(w)
return
}
plainBody := fmt.Sprintf(PasswordResetTextBody, code, resetLink)
if err := a.sendNotification(r.Context(), user.Email, userName,
PasswordResetEmailSubject, plainBody, PasswordResetTemplate, struct {
if err := a.sendNotification(r.Context(), user.Email, PasswordResetNotification,
struct {
Code string
Link string
}{code, resetLink},
Expand Down
48 changes: 36 additions & 12 deletions api/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,39 @@ import (
"time"

qt "github.com/frankban/quicktest"
"github.com/vocdoni/saas-backend/notifications"
)

var codeRgx = fmt.Sprintf("(.{%d})", VerificationCodeLength*2)
var verificationCodeRgx, passwordResetRgx *regexp.Regexp

func init() {
codeRgx := fmt.Sprintf(`(.{%d})`, VerificationCodeLength*2)
// compose notification with the verification code regex needle
verifyNotification := &notifications.Notification{
PlainBody: VerifyAccountNotification.Notification.PlainBody,
}
if err := verifyNotification.ExecTemplate("", struct {
Code string
Link string
}{codeRgx, ""}); err != nil {
panic(err)
}
// clean the notification body to get only the verification code and
// compile the regex
verificationCodeRgx = regexp.MustCompile(strings.Split(verifyNotification.PlainBody, "\n")[0])
// compose notification with the password reset code regex needle
passwordResetNotification := &notifications.Notification{
PlainBody: PasswordResetNotification.Notification.PlainBody,
}
if err := passwordResetNotification.ExecTemplate("", struct {
Code string
Link string
}{codeRgx, ""}); err != nil {
panic(err)
}
// clean the notification body to get only the password reset code and
passwordResetRgx = regexp.MustCompile(strings.Split(passwordResetNotification.PlainBody, "\n")[0])
}

func TestRegisterHandler(t *testing.T) {
c := qt.New(t)
Expand Down Expand Up @@ -185,10 +215,8 @@ func TestVerifyAccountHandler(t *testing.T) {
// get the verification code from the email
mailBody, err := testMailService.FindEmail(context.Background(), testEmail)
c.Assert(err, qt.IsNil)
// create a regex to find the verification code in the email
mailTemplate := strings.Split(VerificationCodeTextBody, "\n")[0]
mailCodeRgx := regexp.MustCompile(fmt.Sprintf(mailTemplate, codeRgx))
mailCode := mailCodeRgx.FindStringSubmatch(mailBody)
// get the verification code from the email using the regex
mailCode := verificationCodeRgx.FindStringSubmatch(mailBody)
// verify the user
verification := mustMarshal(&UserVerification{
Email: testEmail,
Expand All @@ -212,7 +240,7 @@ 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 = mailCodeRgx.FindStringSubmatch(mailBody)
mailCode = verificationCodeRgx.FindStringSubmatch(mailBody)
// verify the user
verification = mustMarshal(&UserVerification{
Email: testEmail,
Expand Down Expand Up @@ -264,9 +292,7 @@ func TestRecoverAndResetPassword(t *testing.T) {
mailBody, err := testMailService.FindEmail(context.Background(), testEmail)
c.Assert(err, qt.IsNil)
// create a regex to find the verification code in the email
mailTemplate := strings.Split(VerificationCodeTextBody, "\n")[0]
mailCodeRgx := regexp.MustCompile(fmt.Sprintf(mailTemplate, codeRgx))
verifyMailCode := mailCodeRgx.FindStringSubmatch(mailBody)
verifyMailCode := passwordResetRgx.FindStringSubmatch(mailBody)
// verify the user
verification := mustMarshal(&UserVerification{
Email: testEmail,
Expand All @@ -292,9 +318,7 @@ func TestRecoverAndResetPassword(t *testing.T) {
mailBody, err = testMailService.FindEmail(context.Background(), testEmail)
c.Assert(err, qt.IsNil)
// update the regex to find the recovery code in the email
mailTemplate = strings.Split(PasswordResetTextBody, "\n")[0]
mailCodeRgx = regexp.MustCompile(fmt.Sprintf(mailTemplate, codeRgx))
passResetMailCode := mailCodeRgx.FindStringSubmatch(mailBody)
passResetMailCode := passwordResetRgx.FindStringSubmatch(mailBody)
// reset the password
newPassword := "password2"
resetPass := mustMarshal(&UserPasswordReset{
Expand Down
Loading

0 comments on commit 4130ff1

Please sign in to comment.