diff --git a/api/const.go b/api/const.go index 9587caa..09ba45c 100644 --- a/api/const.go +++ b/api/const.go @@ -2,8 +2,6 @@ package api import ( "time" - - "github.com/vocdoni/saas-backend/notifications" ) // VerificationCodeExpiration is the duration of the verification code @@ -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 ) diff --git a/api/helpers.go b/api/helpers.go index 4fa33e6..c2ca8ef 100644 --- a/api/helpers.go +++ b/api/helpers.go @@ -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 := ¬ifications.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 diff --git a/api/notifications.go b/api/notifications.go new file mode 100644 index 0000000..1f9a4cf --- /dev/null +++ b/api/notifications.go @@ -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 +} diff --git a/api/organizations.go b/api/organizations.go index b464080..1598fbb 100644 --- a/api/organizations.go +++ b/api/organizations.go @@ -2,7 +2,6 @@ package api import ( "encoding/json" - "fmt" "net/http" "time" @@ -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 diff --git a/api/users.go b/api/users.go index 557b4aa..7fc1537 100644 --- a/api/users.go +++ b/api/users.go @@ -2,7 +2,6 @@ package api import ( "encoding/json" - "fmt" "io" "net/http" "time" @@ -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}, @@ -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}, @@ -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}, diff --git a/api/users_test.go b/api/users_test.go index 05148b2..631751c 100644 --- a/api/users_test.go +++ b/api/users_test.go @@ -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 := ¬ifications.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 := ¬ifications.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) @@ -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, @@ -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, @@ -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, @@ -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{ diff --git a/notifications/notifications.go b/notifications/notifications.go index 46ddb3c..b1da946 100644 --- a/notifications/notifications.go +++ b/notifications/notifications.go @@ -3,7 +3,8 @@ package notifications import ( "bytes" "context" - "html/template" + htmltemplate "html/template" + texttemplate "text/template" ) // Notification represents a notification to be sent, it can be an email or an @@ -22,16 +23,31 @@ type Notification struct { EnableTracking bool } -// ExecTemplate method fills the body of the notification with the template -// provided. It parses the template and inflates it with the data provided. If -// the path is empty, the body of the notification is not modified but no error -// is returned. It returns an error if the template could not be parsed or if -// the template could not be inflated with the data. +// ExecTemplate method fills the plain body and the body of the notification +// with the data provided. It executes the plain body template first and only +// if it is not empty. Then, it executes the template with the path provided +// and the data. The path is the absolute path to the template file. If the +// path is empty, the method skips the template execution. It returns an error +// if the plain body or the template could not be executed. func (n *Notification) ExecTemplate(path string, data any) error { + // if the plain body is not empty, execute the template with the data + // provided + if n.PlainBody != "" { + tmpl, err := texttemplate.New("plain").Parse(n.PlainBody) + if err != nil { + return err + } + buf := new(bytes.Buffer) + if err := tmpl.Execute(buf, data); err != nil { + return err + } + n.PlainBody = buf.String() + n.Body = n.PlainBody + } // if the path is not empty, execute the template with the data provided if path != "" { // parse the template - tmpl, err := template.ParseFiles(path) + tmpl, err := htmltemplate.ParseFiles(path) if err != nil { return err } diff --git a/notifications/smtp/smtp.go b/notifications/smtp/smtp.go index cb08f8b..56ccd60 100644 --- a/notifications/smtp/smtp.go +++ b/notifications/smtp/smtp.go @@ -118,7 +118,9 @@ func (se *SMTPEmail) composeBody(notification *notifications.Notification) ([]by "Content-Type": {"text/plain; charset=\"UTF-8\""}, "Content-Transfer-Encoding": {"7bit"}, }) - textPart.Write([]byte(notification.PlainBody)) + if _, err := textPart.Write([]byte(notification.PlainBody)); err != nil { + return nil, fmt.Errorf("could not write plain text part: %v", err) + } // HTML part htmlPart, _ := writer.CreatePart(textproto.MIMEHeader{ "Content-Type": {"text/html; charset=\"UTF-8\""},