From 2ff488e9ac1ffab9627e9c4f63c7ff3aadace0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Men=C3=A9ndez?= Date: Wed, 27 Nov 2024 09:33:32 +0100 Subject: [PATCH] feature: html email templates (#14) * new html templates for mail notifications * plain text notifications now include the link to finish the process via UI * include plain version when html body is defined in smtp emails, new notification flag to enable or disable link tracking (using filters in X-SMTPAPI header) * new generateVerificationCode method to generate a verification code and link for any kind of database code type, org invite verification code included * new api method to send mail notifications --- .github/workflows/main.yml | 4 +- api/api.go | 3 + api/const.go | 15 +- api/helpers.go | 96 ++ api/notifications.go | 39 + api/organizations.go | 46 +- api/users.go | 106 +- api/users_test.go | 70 +- assets/forgot_password.html | 739 ++++++++++++++ assets/invite_admin.html | 662 +++++++++++++ assets/verification_account.html | 563 +++++++++++ assets/welcome.html | 1010 ++++++++++++++++++++ cmd/service/main.go | 18 +- db/const.go | 5 +- db/helpers.go | 4 - db/users.go | 2 +- db/verifications_test.go | 18 +- go.mod | 2 +- notifications/mailtemplates/definitions.go | 42 + notifications/mailtemplates/templates.go | 123 +++ notifications/notifications.go | 16 +- notifications/smtp/smtp.go | 19 +- notifications/twilio/sms.go | 2 +- 23 files changed, 3464 insertions(+), 140 deletions(-) create mode 100644 api/notifications.go create mode 100644 assets/forgot_password.html create mode 100644 assets/invite_admin.html create mode 100644 assets/verification_account.html create mode 100644 assets/welcome.html create mode 100644 notifications/mailtemplates/definitions.go create mode 100644 notifications/mailtemplates/templates.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ffac0d2..b3cbf3d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,7 +9,7 @@ jobs: - name: Install Go uses: actions/setup-go@v5 with: - go-version: 1.22.x + go-version: 1.23.x cache: true - name: Run golangci-lint # run: | @@ -31,7 +31,7 @@ jobs: - name: Set up Go environment uses: actions/setup-go@v5 with: - go-version: "1.22.x" + go-version: "1.23.x" - name: Tidy go module run: | go mod tidy diff --git a/api/api.go b/api/api.go index c31a5a2..4ce19f4 100644 --- a/api/api.go +++ b/api/api.go @@ -32,6 +32,7 @@ type APIConfig struct { Client *apiclient.HTTPclient Account *account.Account MailService notifications.NotificationService + WebAppURL string // FullTransparentMode if true allows signing all transactions and does not // modify any of them. FullTransparentMode bool @@ -52,6 +53,7 @@ type API struct { account *account.Account mail notifications.NotificationService secret string + webAppURL string transparentMode bool stripe *stripe.StripeClient subscriptions *subscriptions.Subscriptions @@ -71,6 +73,7 @@ func New(conf *APIConfig) *API { account: conf.Account, mail: conf.MailService, secret: conf.Secret, + webAppURL: conf.WebAppURL, transparentMode: conf.FullTransparentMode, stripe: conf.StripeClient, subscriptions: conf.Subscriptions, diff --git a/api/const.go b/api/const.go index 5753408..e4e81df 100644 --- a/api/const.go +++ b/api/const.go @@ -1,6 +1,8 @@ package api -import "time" +import ( + "time" +) // VerificationCodeExpiration is the duration of the verification code // before it is invalidated @@ -9,14 +11,7 @@ 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: " - // 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" - // InvitationExpiration is the duration of the invitation code before it is invalidated + // 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 9dc63e7..91c0468 100644 --- a/api/helpers.go +++ b/api/helpers.go @@ -3,13 +3,18 @@ package api import ( "context" "encoding/json" + "fmt" "net/http" + "net/url" "time" "github.com/go-chi/chi/v5" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/vocdoni/saas-backend/db" + "github.com/vocdoni/saas-backend/internal" + "github.com/vocdoni/saas-backend/notifications/mailtemplates" "go.vocdoni.io/dvote/log" + "go.vocdoni.io/dvote/util" ) // organizationFromRequest helper function allows to get the organization info @@ -50,6 +55,97 @@ func (a *API) buildLoginResponse(id string) (*LoginResponse, error) { return &lr, nil } +// buildWebAppURL method allows to build a URL for the web application using +// the path and the parameters provided. It returns the URL as a string and an +// error if the URL could not be built. It encodes the parameters in the query +// string of the URL to prevent any issues with special characters. It returns +// the URL as a string and an error if the URL could not be built. +func (a *API) buildWebAppURL(path string, params map[string]any) (string, error) { + // parse the web app URL with the path provided + url, err := url.Parse(a.webAppURL + path) + if err != nil { + return "", err + } + // encode the parameters in the query string of the URL + q := url.Query() + for k, v := range params { + q.Set(k, fmt.Sprint(v)) + } + // include the encoded query string in the URL + url.RawQuery = q.Encode() + return url.String(), nil +} + +// generateVerificationCodeAndLink method generates and stores in the database +// a new verification code for the target provided according to the database +// code type selected. Then it generates a verification link to the web app +// with correct web app uri and link parameters for the code type selected. +// It returns the generated verification code, the link to the web app and +// an error if the verification code could not be generated or stored in the +// database. +func (a *API) generateVerificationCodeAndLink(target any, codeType db.CodeType) (string, string, error) { + // generate verification code if the mail service is available, if not + // the verification code will not be sent but stored in the database + // generated with just the user email to mock the verification process + var code string + if a.mail != nil { + code = util.RandomHex(VerificationCodeLength) + } + var webAppURI string + var linkParams map[string]any + switch codeType { + case db.CodeTypeVerifyAccount, db.CodeTypePasswordReset: + // the target should be a database user + user, ok := target.(*db.User) + if !ok { + return "", "", fmt.Errorf("invalid target type") + } + // generate the verification code for the user and the expiration time + hashCode := internal.HashVerificationCode(user.Email, code) + exp := time.Now().Add(VerificationCodeExpiration) + // store the verification code in the database + if err := a.db.SetVerificationCode(&db.User{ID: user.ID}, hashCode, codeType, exp); err != nil { + return "", "", err + } + // set the web app URI and the link parameters + webAppURI = mailtemplates.VerifyAccountNotification.WebAppURI + if codeType == db.CodeTypePasswordReset { + webAppURI = mailtemplates.PasswordResetNotification.WebAppURI + } + linkParams = map[string]any{ + "email": user.Email, + "code": code, + } + case db.CodeTypeOrgInvite: + // the target should be a database organization invite + invite, ok := target.(*db.OrganizationInvite) + if !ok { + return "", "", fmt.Errorf("invalid target type") + } + // set the verification code for the organization invite and the + // expiration time + invite.InvitationCode = code + invite.Expiration = time.Now().Add(InvitationExpiration) + // store the organization invite in the database + if err := a.db.CreateInvitation(invite); err != nil { + return "", "", err + } + // set the web app URI and the link parameters + webAppURI = mailtemplates.InviteNotification.WebAppURI + linkParams = map[string]any{ + "email": invite.NewUserEmail, + "code": invite.InvitationCode, + "address": invite.OrganizationAddress, + } + default: + return "", "", fmt.Errorf("invalid code type") + } + // generate the verification link to the web app with the selected uri + // and the link parameters + verificationLink, err := a.buildWebAppURL(webAppURI, linkParams) + return code, verificationLink, err +} + // httpWriteJSON helper function allows to write a JSON response. func httpWriteJSON(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") diff --git a/api/notifications.go b/api/notifications.go new file mode 100644 index 0000000..753625a --- /dev/null +++ b/api/notifications.go @@ -0,0 +1,39 @@ +package api + +import ( + "context" + "fmt" + "time" + + "github.com/vocdoni/saas-backend/internal" + "github.com/vocdoni/saas-backend/notifications/mailtemplates" +) + +// sendMail method sends a notification to the email provided. It requires the +// email template and the data to fill it. It executes the mail template with +// the data to get the notification and sends it with the recipient email +// address provided. It returns an error if the mail service is available and +// the notification could not be sent or the email address is invalid. If the +// mail service is not available, it does nothing. +func (a *API) sendMail(ctx context.Context, to string, mail mailtemplates.MailTemplate, data any) error { + if a.mail != nil { + ctx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + // check if the email address is valid + if !internal.ValidEmail(to) { + return fmt.Errorf("invalid email address") + } + // execute the mail template to get the notification + notification, err := mail.ExecTemplate(data) + if err != nil { + return err + } + // set the recipient email address + notification.ToAddress = to + // send the mail 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 45e9175..7c733f2 100644 --- a/api/organizations.go +++ b/api/organizations.go @@ -1,16 +1,14 @@ package api import ( - "context" "encoding/json" - "fmt" "net/http" "time" "github.com/vocdoni/saas-backend/account" "github.com/vocdoni/saas-backend/db" "github.com/vocdoni/saas-backend/internal" - "github.com/vocdoni/saas-backend/notifications" + "github.com/vocdoni/saas-backend/notifications/mailtemplates" "go.vocdoni.io/dvote/log" ) @@ -283,36 +281,34 @@ func (a *API) inviteOrganizationMemberHandler(w http.ResponseWriter, r *http.Req return } // create new invitation - inviteCode := internal.RandomHex(VerificationCodeLength) - if err := a.db.CreateInvitation(&db.OrganizationInvite{ - InvitationCode: inviteCode, + orgInvite := &db.OrganizationInvite{ OrganizationAddress: org.Address, NewUserEmail: invite.Email, Role: db.UserRole(invite.Role), CurrentUserID: user.ID, - Expiration: time.Now().Add(InvitationExpiration), - }); err != nil { + } + // generate the verification code and the verification link + code, link, err := a.generateVerificationCodeAndLink(orgInvite, db.CodeTypeOrgInvite) + if err != nil { if err == db.ErrAlreadyExists { ErrDuplicateConflict.With("user is already invited to the organization").Write(w) return } - ErrGenericInternalServerError.Withf("could not create invitation: %v", err).Write(w) - return - } - // send the invitation email - ctx, cancel := context.WithTimeout(r.Context(), time.Second*10) - defer cancel() - // send the verification code via email if the mail service is available - if a.mail != nil { - if err := a.mail.SendNotification(ctx, ¬ifications.Notification{ - ToName: fmt.Sprintf("%s %s", user.FirstName, user.LastName), - ToAddress: invite.Email, - Subject: InvitationEmailSubject, - Body: fmt.Sprintf(InvitationTextBody, org.Address, inviteCode), - }); err != nil { - ErrGenericInternalServerError.Withf("could not send verification code: %v", err).Write(w) - return - } + ErrGenericInternalServerError.Withf("could not create the invite: %v", err).Write(w) + return + } + // send the invitation mail to invited user email with the invite code and + // the invite link + if err := a.sendMail(r.Context(), invite.Email, mailtemplates.InviteNotification, + struct { + Organization string + Code string + Link string + }{org.Address, code, link}, + ); err != nil { + log.Warnw("could not send verification code email", "error", err) + ErrGenericInternalServerError.Write(w) + return } httpWriteOK(w) } diff --git a/api/users.go b/api/users.go index 8edf5c4..d4b9fee 100644 --- a/api/users.go +++ b/api/users.go @@ -1,59 +1,17 @@ package api import ( - "context" "encoding/json" - "fmt" "io" "net/http" "time" "github.com/vocdoni/saas-backend/db" "github.com/vocdoni/saas-backend/internal" - "github.com/vocdoni/saas-backend/notifications" + "github.com/vocdoni/saas-backend/notifications/mailtemplates" "go.vocdoni.io/dvote/log" - "go.vocdoni.io/dvote/util" ) -// sendUserCode method allows to send a code to the user via email or SMS. It -// generates a verification code and stores it in the database associated to -// the user email. If the mail service is available, it sends the verification -// code via email. If the SMS service is available, it sends the verification -// code via SMS. The code is generated associated a the type of code received, -// that can be either a verification code or a password reset code. Other types -// of codes can be added in the future. If neither the mail service nor the SMS -// service are available, the verification code will be empty but stored in the -// database to mock the verification process in any case. -func (a *API) sendUserCode(ctx context.Context, user *db.User, t db.CodeType) error { - // generate verification code if the mail service is available, if not - // the verification code will not be sent but stored in the database - // generated with just the user email to mock the verification process - var code string - if a.mail != nil { - code = util.RandomHex(VerificationCodeLength) - } - // store the verification code in the database - hashCode := internal.HashVerificationCode(user.Email, code) - exp := time.Now().Add(VerificationCodeExpiration) - if err := a.db.SetVerificationCode(&db.User{ID: user.ID}, hashCode, t, exp); err != nil { - return err - } - 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 { - if err := a.mail.SendNotification(ctx, ¬ifications.Notification{ - ToName: fmt.Sprintf("%s %s", user.FirstName, user.LastName), - ToAddress: user.Email, - Subject: VerificationCodeEmailSubject, - Body: VerificationCodeTextBody + code, - }); err != nil { - return err - } - } - return nil -} - // registerHandler handles the register request. It creates a new user in the database. func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) { userInfo := &UserInfo{} @@ -111,9 +69,23 @@ func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) { FirstName: userInfo.FirstName, LastName: userInfo.LastName, } - if err := a.sendUserCode(r.Context(), newUser, db.CodeTypeAccountVerification); err != nil { + // generate a new verification code + code, link, err := a.generateVerificationCodeAndLink(newUser, db.CodeTypeVerifyAccount) + if err != nil { + log.Warnw("could not generate verification code", "error", err) + ErrGenericInternalServerError.Write(w) + return + } + // send the verification mail to the user email with the verification code + // and the verification link + if err := a.sendMail(r.Context(), userInfo.Email, + mailtemplates.VerifyAccountNotification, struct { + Code string + Link string + }{code, link}, + ); err != nil { log.Warnw("could not send verification code", "error", err) - ErrGenericInternalServerError.WithErr(err).Write(w) + ErrGenericInternalServerError.Write(w) return } // send the token back to the user @@ -135,7 +107,6 @@ func (a *API) verifyUserAccountHandler(w http.ResponseWriter, r *http.Request) { ErrMalformedBody.Write(w) return } - // check the email and verification code are not empty only if the mail // service is available if a.mail != nil && (verification.Code == "" || verification.Email == "") { @@ -158,7 +129,7 @@ func (a *API) verifyUserAccountHandler(w http.ResponseWriter, r *http.Request) { return } // get the verification code from the database - code, err := a.db.UserVerificationCode(user, db.CodeTypeAccountVerification) + code, err := a.db.UserVerificationCode(user, db.CodeTypeVerifyAccount) if err != nil { if err != db.ErrNotFound { log.Warnw("could not get verification code", "error", err) @@ -227,7 +198,7 @@ func (a *API) userVerificationCodeInfoHandler(w http.ResponseWriter, r *http.Req return } // get the verification code from the database - code, err := a.db.UserVerificationCode(user, db.CodeTypeAccountVerification) + code, err := a.db.UserVerificationCode(user, db.CodeTypeVerifyAccount) if err != nil { if err != db.ErrNotFound { log.Warnw("could not get verification code", "error", err) @@ -276,7 +247,7 @@ func (a *API) resendUserVerificationCodeHandler(w http.ResponseWriter, r *http.R return } // get the verification code from the database - code, err := a.db.UserVerificationCode(user, db.CodeTypeAccountVerification) + code, err := a.db.UserVerificationCode(user, db.CodeTypeVerifyAccount) if err != nil { if err != db.ErrNotFound { log.Warnw("could not get verification code", "error", err) @@ -289,8 +260,21 @@ func (a *API) resendUserVerificationCodeHandler(w http.ResponseWriter, r *http.R ErrVerificationCodeValid.Write(w) return } - // set a new code and send it - if err := a.sendUserCode(r.Context(), user, db.CodeTypeAccountVerification); err != nil { + // generate a new verification code + newCode, link, err := a.generateVerificationCodeAndLink(user, db.CodeTypeVerifyAccount) + if err != nil { + log.Warnw("could not generate verification code", "error", err) + ErrGenericInternalServerError.Write(w) + return + } + // send the verification mail to the user email with the verification code + // and the verification link + if err := a.sendMail(r.Context(), user.Email, mailtemplates.VerifyAccountNotification, + struct { + Code string + Link string + }{newCode, link}, + ); err != nil { log.Warnw("could not send verification code", "error", err) ErrGenericInternalServerError.Write(w) return @@ -464,10 +448,24 @@ func (a *API) recoverUserPasswordHandler(w http.ResponseWriter, r *http.Request) ErrGenericInternalServerError.Write(w) return } - // if the user is verified generate a new verification code and send it + // check the user is verified if user.Verified { - if err := a.sendUserCode(r.Context(), user, db.CodeTypePasswordReset); err != nil { - log.Warnw("could not send verification code", "error", err) + // generate a new verification code + code, link, err := a.generateVerificationCodeAndLink(user, db.CodeTypePasswordReset) + if err != nil { + log.Warnw("could not generate verification code", "error", err) + ErrGenericInternalServerError.Write(w) + return + } + // send the password reset mail to the user email with the verification + // code and the verification link + if err := a.sendMail(r.Context(), user.Email, mailtemplates.PasswordResetNotification, + struct { + Code string + Link string + }{code, link}, + ); err != nil { + log.Warnw("could not send reset passworod code", "error", err) ErrGenericInternalServerError.Write(w) return } diff --git a/api/users_test.go b/api/users_test.go index 29b34ae..8a13bda 100644 --- a/api/users_test.go +++ b/api/users_test.go @@ -12,8 +12,50 @@ import ( "time" qt "github.com/frankban/quicktest" + "github.com/vocdoni/saas-backend/notifications" + "github.com/vocdoni/saas-backend/notifications/mailtemplates" ) +var verificationCodeRgx, passwordResetRgx *regexp.Regexp + +func init() { + // create a regex to find the verification code in the email + codeRgx := fmt.Sprintf(`(.{%d})`, VerificationCodeLength*2) + // load the email templates + if err := mailtemplates.Load("../assets"); err != nil { + panic(err) + } + // wrap the mail template execution to force plain body and set the regex + // needle as the verification code + testTemplateExec := func(mt mailtemplates.MailTemplate) (*notifications.Notification, error) { + n, err := mt.ExecTemplate(struct { + Code string + Link string + }{codeRgx, ""}) + if err != nil { + return nil, err + } + // force plain body + n.Body = n.PlainBody + return n, nil + } + // compose notification with the verification code regex needle + verifyNotification, err := testTemplateExec(mailtemplates.VerifyAccountNotification) + if 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, err := testTemplateExec(mailtemplates.PasswordResetNotification) + if 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) defer func() { @@ -183,9 +225,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 - mailCodeRgx := regexp.MustCompile(fmt.Sprintf(`%s(.{%d})`, VerificationCodeTextBody, VerificationCodeLength*2)) - 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, @@ -209,7 +250,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, @@ -257,22 +298,11 @@ func TestRecoverAndResetPassword(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Body.Close(), qt.IsNil) - // try to recover the password before verifying the user (should fail) - jsonRecover := mustMarshal(&UserInfo{ - Email: testEmail, - }) - req, err = http.NewRequest(http.MethodPost, testURL(usersRecoveryPasswordEndpoint), bytes.NewBuffer(jsonRecover)) - c.Assert(err, qt.IsNil) - resp, err = http.DefaultClient.Do(req) - c.Assert(err, qt.IsNil) - c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) - c.Assert(resp.Body.Close(), qt.IsNil) - // get the verification code from the email + // verify the user (to be able to recover the password) mailBody, err := testMailService.FindEmail(context.Background(), testEmail) c.Assert(err, qt.IsNil) // 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) + verifyMailCode := passwordResetRgx.FindStringSubmatch(mailBody) // verify the user verification := mustMarshal(&UserVerification{ Email: testEmail, @@ -285,6 +315,9 @@ func TestRecoverAndResetPassword(t *testing.T) { c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Body.Close(), qt.IsNil) // try to recover the password after verifying the user + jsonRecover := mustMarshal(&UserInfo{ + Email: testEmail, + }) req, err = http.NewRequest(http.MethodPost, testURL(usersRecoveryPasswordEndpoint), bytes.NewBuffer(jsonRecover)) c.Assert(err, qt.IsNil) resp, err = http.DefaultClient.Do(req) @@ -294,7 +327,8 @@ 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 := mailCodeRgx.FindStringSubmatch(mailBody) + // update the regex to find the recovery code in the email + passResetMailCode := passwordResetRgx.FindStringSubmatch(mailBody) // reset the password newPassword := "password2" resetPass := mustMarshal(&UserPasswordReset{ diff --git a/assets/forgot_password.html b/assets/forgot_password.html new file mode 100644 index 0000000..037da0c --- /dev/null +++ b/assets/forgot_password.html @@ -0,0 +1,739 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + +
+
+ + + + \ No newline at end of file diff --git a/assets/invite_admin.html b/assets/invite_admin.html new file mode 100644 index 0000000..9e526b8 --- /dev/null +++ b/assets/invite_admin.html @@ -0,0 +1,662 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + +
+
+ + + + \ No newline at end of file diff --git a/assets/verification_account.html b/assets/verification_account.html new file mode 100644 index 0000000..87244b9 --- /dev/null +++ b/assets/verification_account.html @@ -0,0 +1,563 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + +
+
+
  +                                 +                           +
+ + + \ No newline at end of file diff --git a/assets/welcome.html b/assets/welcome.html new file mode 100644 index 0000000..5956a28 --- /dev/null +++ b/assets/welcome.html @@ -0,0 +1,1010 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + +
+
+ + + + \ No newline at end of file diff --git a/cmd/service/main.go b/cmd/service/main.go index ec7b194..c87c560 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -11,6 +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/mailtemplates" "github.com/vocdoni/saas-backend/notifications/smtp" "github.com/vocdoni/saas-backend/stripe" "github.com/vocdoni/saas-backend/subscriptions" @@ -23,11 +24,13 @@ func main() { flag.StringP("host", "h", "0.0.0.0", "listen address") flag.IntP("port", "p", 8080, "listen port") flag.StringP("secret", "s", "", "API secret") + flag.StringP("vocdoniApi", "v", "https://api-dev.vocdoni.net/v2", "vocdoni node remote API URL") + flag.StringP("webURL", "w", "https://saas-dev.vocdoni.app", "The URL of the web application") flag.StringP("mongoURL", "m", "", "The URL of the MongoDB server") flag.StringP("mongoDB", "d", "saasdb", "The name of the MongoDB database") - 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("emailTemplatesPath", "./assets", "path to the email templates") flag.String("smtpServer", "", "SMTP server") flag.Int("smtpPort", 587, "SMTP port") flag.String("smtpUsername", "", "SMTP username") @@ -48,13 +51,16 @@ func main() { host := viper.GetString("host") port := viper.GetInt("port") apiEndpoint := viper.GetString("vocdoniApi") + webURL := viper.GetString("webURL") secret := viper.GetString("secret") if secret == "" { log.Fatal("secret is required") } + // MongoDB vars mongoURL := viper.GetString("mongoURL") mongoDB := viper.GetString("mongoDB") // email vars + emailTemplatesPath := viper.GetString("emailTemplatesPath") smtpServer := viper.GetString("smtpServer") smtpPort := viper.GetInt("smtpPort") smtpUsername := viper.GetString("smtpUsername") @@ -109,6 +115,7 @@ func main() { DB: database, Client: apiClient, Account: acc, + WebAppURL: webURL, FullTransparentMode: fullTransparentMode, StripeClient: stripeClient, } @@ -129,6 +136,15 @@ func main() { }); err != nil { log.Fatalf("could not create the email service: %v", err) } + // load email templates if the path is set + if emailTemplatesPath != "" { + if err := mailtemplates.Load(emailTemplatesPath); err != nil { + log.Fatalf("could not load email templates: %v", err) + } + log.Infow("email templates loaded", + "path", emailTemplatesPath, + "templates", len(mailtemplates.AvailableTemplates)) + } log.Infow("email service created", "from", fmt.Sprintf("%s <%s>", emailFromName, emailFromAddress)) } subscriptions := subscriptions.New(&subscriptions.SubscriptionsConfig{ diff --git a/db/const.go b/db/const.go index 6f730d9..11a7b4d 100644 --- a/db/const.go +++ b/db/const.go @@ -21,8 +21,9 @@ const ( ProfessionalCollegeType OrganizationType = "professional_college" OthersType OrganizationType = "others" // verification code types - CodeTypeAccountVerification CodeType = "account" - CodeTypePasswordReset CodeType = "password" + CodeTypeVerifyAccount CodeType = "verify_account" + CodeTypePasswordReset CodeType = "password_reset" + CodeTypeOrgInvite CodeType = "organization_invite" ) // writableRoles is a map that contains if the role is writable or not diff --git a/db/helpers.go b/db/helpers.go index 59e4cf4..9f55eb3 100644 --- a/db/helpers.go +++ b/db/helpers.go @@ -233,9 +233,5 @@ func ReadPlanJSON() ([]*Plan, error) { if err != nil { return nil, err } - // print plans - for _, sub := range plans { - fmt.Println(sub) - } return plans, nil } diff --git a/db/users.go b/db/users.go index 55ba849..5c236d7 100644 --- a/db/users.go +++ b/db/users.go @@ -188,7 +188,7 @@ func (ms *MongoStorage) VerifyUserAccount(user *User) error { return err } // remove the verification code - return ms.delVerificationCode(ctx, user.ID, CodeTypeAccountVerification) + return ms.delVerificationCode(ctx, user.ID, CodeTypeVerifyAccount) } // IsMemberOf method checks if the user with the given email is a member of the diff --git a/db/verifications_test.go b/db/verifications_test.go index 9ce9305..073a4c4 100644 --- a/db/verifications_test.go +++ b/db/verifications_test.go @@ -23,18 +23,18 @@ func TestUserVerificationCode(t *testing.T) { }) c.Assert(err, qt.IsNil) - _, err = db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + _, err = db.UserVerificationCode(&User{ID: userID}, CodeTypeVerifyAccount) c.Assert(err, qt.Equals, ErrNotFound) testCode := "testCode" - c.Assert(db.SetVerificationCode(&User{ID: userID}, testCode, CodeTypeAccountVerification, time.Now()), qt.IsNil) + c.Assert(db.SetVerificationCode(&User{ID: userID}, testCode, CodeTypeVerifyAccount, time.Now()), qt.IsNil) - code, err := db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + code, err := db.UserVerificationCode(&User{ID: userID}, CodeTypeVerifyAccount) c.Assert(err, qt.IsNil) c.Assert(code.Code, qt.Equals, testCode) c.Assert(db.VerifyUserAccount(&User{ID: userID}), qt.IsNil) - _, err = db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + _, err = db.UserVerificationCode(&User{ID: userID}, CodeTypeVerifyAccount) c.Assert(err, qt.Equals, ErrNotFound) } @@ -47,7 +47,7 @@ func TestSetVerificationCode(t *testing.T) { }() nonExistingUserID := uint64(100) - err := db.SetVerificationCode(&User{ID: nonExistingUserID}, "testCode", CodeTypeAccountVerification, time.Now()) + err := db.SetVerificationCode(&User{ID: nonExistingUserID}, "testCode", CodeTypeVerifyAccount, time.Now()) c.Assert(err, qt.Equals, ErrNotFound) userID, err := db.SetUser(&User{ @@ -59,16 +59,16 @@ func TestSetVerificationCode(t *testing.T) { c.Assert(err, qt.IsNil) testCode := "testCode" - c.Assert(db.SetVerificationCode(&User{ID: userID}, testCode, CodeTypeAccountVerification, time.Now()), qt.IsNil) + c.Assert(db.SetVerificationCode(&User{ID: userID}, testCode, CodeTypeVerifyAccount, time.Now()), qt.IsNil) - code, err := db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + code, err := db.UserVerificationCode(&User{ID: userID}, CodeTypeVerifyAccount) c.Assert(err, qt.IsNil) c.Assert(code.Code, qt.Equals, testCode) testCode = "testCode2" - c.Assert(db.SetVerificationCode(&User{ID: userID}, testCode, CodeTypeAccountVerification, time.Now()), qt.IsNil) + c.Assert(db.SetVerificationCode(&User{ID: userID}, testCode, CodeTypeVerifyAccount, time.Now()), qt.IsNil) - code, err = db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + code, err = db.UserVerificationCode(&User{ID: userID}, CodeTypeVerifyAccount) c.Assert(err, qt.IsNil) c.Assert(code.Code, qt.Equals, testCode) } diff --git a/go.mod b/go.mod index 9981691..68ac1d8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/vocdoni/saas-backend -go 1.22.5 +go 1.23.3 require ( github.com/docker/go-connections v0.5.0 diff --git a/notifications/mailtemplates/definitions.go b/notifications/mailtemplates/definitions.go new file mode 100644 index 0000000..7791a7c --- /dev/null +++ b/notifications/mailtemplates/definitions.go @@ -0,0 +1,42 @@ +package mailtemplates + +import "github.com/vocdoni/saas-backend/notifications" + +// VerifyAccountNotification is the notification to be sent when a user creates +// an account and needs to verify it. +var VerifyAccountNotification = MailTemplate{ + File: "verification_account", + Placeholder: 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}}`, + }, + WebAppURI: "/account/verify", +} + +// PasswordResetNotification is the notification to be sent when a user requests +// a password reset. +var PasswordResetNotification = MailTemplate{ + File: "forgot_password", + Placeholder: 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}}`, + }, + WebAppURI: "/account/password/reset", +} + +// InviteNotification is the notification to be sent when a user is invited +// to be an admin of an organization. +var InviteNotification = MailTemplate{ + File: "invite_admin", + Placeholder: 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}}`, + }, + WebAppURI: "/account/invite", +} diff --git a/notifications/mailtemplates/templates.go b/notifications/mailtemplates/templates.go new file mode 100644 index 0000000..d85ba6d --- /dev/null +++ b/notifications/mailtemplates/templates.go @@ -0,0 +1,123 @@ +package mailtemplates + +import ( + "bytes" + "fmt" + htmltemplate "html/template" + "os" + "path/filepath" + "strings" + texttemplate "text/template" + + "github.com/vocdoni/saas-backend/notifications" +) + +// AvailableTemplates is a map that stores the filename and the absolute path +// of the email templates. The filename is the key and the path is the value. +var AvailableTemplates map[TemplateFile]string + +// TemplateFile represents an email template key. Every email template should +// have a key that identifies it, which is the filename without the extension. +type TemplateFile string + +// MailTemplate struct represents an email template. It includes the file key +// and the notification placeholder to be sent. The file key is the filename +// of the template without the extension. The notification placeholder includes +// the plain body template to be used as a fallback for email clients that do +// not support HTML, and the mail subject. +type MailTemplate struct { + File TemplateFile + Placeholder notifications.Notification + WebAppURI string +} + +// Load function reads the email templates from the specified directory. +// Returns a map with the filename and file absolute path. The filename is +// the key and the path is the value. +func Load(path string) error { + // create a map to store the filename and file content + htmlFiles := make(map[TemplateFile]string) + // walk through the directory and read each file + if err := filepath.Walk(path, func(fPath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + // only process regular files and files with a ".html" extension + if !info.IsDir() && strings.HasSuffix(info.Name(), ".html") { + // get the absolute path of the file + absPath, err := filepath.Abs(fPath) + if err != nil { + return err + } + // remove the ".html" extension from the filename + filename := strings.TrimSuffix(info.Name(), ".html") + // store the filename and content in the map + htmlFiles[TemplateFile(filename)] = absPath + } + return nil + }); err != nil { + return err + } + AvailableTemplates = htmlFiles + return nil +} + +// ExecTemplate method checks if the template file exists in the available +// mail templates and if it does, it executes the template with the data +// provided. If it doesn't exist, it returns an error. If the plain body +// placeholder is not empty, it executes the plain text template with the +// data provided. It returns the notification with the body and plain body +// filled with the data provided. +func (mt MailTemplate) ExecTemplate(data any) (*notifications.Notification, error) { + path, ok := AvailableTemplates[mt.File] + if !ok { + return nil, fmt.Errorf("template not found") + } + // create a notification with the plain body placeholder inflated + n, err := mt.ExecPlain(data) + if err != nil { + return nil, err + } + // set the mail subject + n.Subject = mt.Placeholder.Subject + // parse the html template file + tmpl, err := htmltemplate.ParseFiles(path) + if err != nil { + return nil, err + } + // inflate the template with the data + buf := new(bytes.Buffer) + if err := tmpl.Execute(buf, data); err != nil { + return nil, err + } + // set the body of the notification + n.Body = buf.String() + return n, nil +} + +// ExecPlain method executes the plain body placeholder template with the data +// provided. If the placeholder plain body is not empty, it executes the plain +// text template with the data provided. If it is empty, just returns an empty +// notification. It resulting notification and an error if the defined template +// could not be executed. +// +// This method also allows to notifications services that do not support HTML +// emails to use a mail template. +func (mt MailTemplate) ExecPlain(data any) (*notifications.Notification, error) { + n := ¬ifications.Notification{} + if mt.Placeholder.PlainBody != "" { + // parse the placeholder plain body template + tmpl, err := texttemplate.New("plain").Parse(mt.Placeholder.PlainBody) + if err != nil { + return nil, err + } + // inflate the template with the data + buf := new(bytes.Buffer) + if err := tmpl.Execute(buf, data); err != nil { + return nil, err + } + // return the notification with the plain body filled with the data + n.PlainBody = buf.String() + } + return n, nil +} diff --git a/notifications/notifications.go b/notifications/notifications.go index ded00c5..fce016c 100644 --- a/notifications/notifications.go +++ b/notifications/notifications.go @@ -5,13 +5,17 @@ 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. +// while the recipient's number is used for SMS. The EnableTracking flag +// indicates if the links that the notification contains should be tracked or +// not. type Notification struct { - ToName string - ToAddress string - ToNumber string - Subject string - Body string + ToName string + ToAddress string + ToNumber string + Subject string + Body string + PlainBody string + EnableTracking bool } // NotificationService is the interface that must be implemented by any diff --git a/notifications/smtp/smtp.go b/notifications/smtp/smtp.go index 68a08c6..56ccd60 100644 --- a/notifications/smtp/smtp.go +++ b/notifications/smtp/smtp.go @@ -12,6 +12,8 @@ import ( "github.com/vocdoni/saas-backend/notifications" ) +var disableTrackingFilter = []byte(`{"filters":{"clicktrack":{"settings":{"enable":0,"enable_text":false}}}}`) + // 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 @@ -99,6 +101,9 @@ func (se *SMTPEmail) composeBody(notification *notifications.Notification) ([]by 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)) + if !notification.EnableTracking { + headers.WriteString(fmt.Sprintf("X-SMTPAPI: %s\r\n", disableTrackingFilter)) + } 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 @@ -108,12 +113,14 @@ func (se *SMTPEmail) composeBody(notification *notifications.Notification) ([]by 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)) + // plain text part + textPart, _ := writer.CreatePart(textproto.MIMEHeader{ + "Content-Type": {"text/plain; charset=\"UTF-8\""}, + "Content-Transfer-Encoding": {"7bit"}, + }) + 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\""}, diff --git a/notifications/twilio/sms.go b/notifications/twilio/sms.go index cbb482b..e379f28 100644 --- a/notifications/twilio/sms.go +++ b/notifications/twilio/sms.go @@ -65,7 +65,7 @@ func (tsms *TwilioSMS) SendNotification(ctx context.Context, notification *notif params := &api.CreateMessageParams{} params.SetTo(notification.ToNumber) params.SetFrom(tsms.config.FromNumber) - params.SetBody(notification.Body) + params.SetBody(notification.PlainBody) // create a channel to handle errors errCh := make(chan error, 1) go func() {