Skip to content

Commit

Permalink
feature: html email templates (#14)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
lucasmenendez authored Nov 27, 2024
1 parent 1aa9fbb commit 2ff488e
Show file tree
Hide file tree
Showing 23 changed files with 3,464 additions and 140 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
15 changes: 5 additions & 10 deletions api/const.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package api

import "time"
import (
"time"
)

// VerificationCodeExpiration is the duration of the verification code
// before it is invalidated
Expand All @@ -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
)
96 changes: 96 additions & 0 deletions api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
39 changes: 39 additions & 0 deletions api/notifications.go
Original file line number Diff line number Diff line change
@@ -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
}
46 changes: 21 additions & 25 deletions api/organizations.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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, &notifications.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)
}
Expand Down
Loading

0 comments on commit 2ff488e

Please sign in to comment.