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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Forgot
+ your
+ password?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ To
+ reset
+ your
+ password,
+ click
+ the
+ button
+ below.
+ The
+ link
+ will
+ self-destruct
+ after
+ five
+ days.
+
+ If
+ you
+ do
+ not
+ want
+ to
+ change
+ your
+ password
+ or
+ didn't
+ request
+ a
+ reset,
+ you
+ can
+ ignore
+ and
+ delete
+ this
+ email.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Vocdoni
+ is
+ a
+ blockchain-based
+ voting
+ platform
+ designed
+ to
+ ensure
+ secure,
+ transparent,
+ and
+ tamper-proof
+ digital
+ voting.
+ It
+ leverages
+ decentralized
+ technologies
+ to
+ provide
+ scalable
+ and
+ verifiable
+ election
+ processes
+ for
+ organizations
+ and
+ communities.
+ Vocdoni
+ aims
+ to
+ enhance
+ democratic
+ participation
+ through
+ innovative
+ and
+ accessible
+ voting
+ solutions.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Vocdoni
+ Association.
+ All
+ rights
+ reserved
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You
+ have
+ been
+ invited
+ to
+ join
+ Voconi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The
+ organization
+ with
+ {{.Organization}}
+ address
+ has
+ invited
+ you
+ to
+ join
+ their
+ voting
+ platform.
+
+ If the link does not work here is your invitation code:
+
+ {{.Code}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Vocdoni
+ is
+ a
+ global
+ digital
+ voting
+ protocol
+ designed
+ to
+ make
+ every
+ vote
+ verifiable,
+ secure,
+ and
+ auditable.
+ Our
+ platform
+ is
+ GDPR-compliant,
+ ensuring
+ your
+ privacy
+ while
+ offering
+ a
+ reliable
+ and
+ affordable
+ solution
+ for
+ digital
+ elections
+ worldwide.
+ Vocdoni
+ Association.
+ All
+ rights
+ reserved
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Flash
+ Inc.
+ All
+ rights
+ reserved
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Please verify your email 😀
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ To use Vocdoni SaaS, click the verification
+ button. This helps keep your account secure.
+ You're receiving this email because you have
+ an account in Vocdoni App. If you are not sure
+ why you're receiving this, please contact us
+ by replying to this email.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ By joining Vocdoni,
+ you're now part of a global digital voting
+ protocol that's secure, verifiable,
+ auditable, and private. Experience the future of
+ elections with a platform designed to protect
+ your vote and ensure transparency, all while
+ being accessible and affordable.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Vocdoni
+ is a global digital voting protocol designed to make every vote
+ verifiable, secure, and auditable. Our platform is GDPR-compliant,
+ ensuring your privacy while offering a reliable and affordable solution
+ for digital elections worldwide.
Vocdoni
+ Association. All rights reserved
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Welcome
+ to
+ Vocdoni!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ We
+ are
+ thrilled
+ to
+ have
+ you
+ on
+ board
+ as
+ part
+ of
+ our
+ mission
+ to
+ revolutionize
+ digital
+ voting
+ with
+ secure,
+ transparent,
+ and
+ tamper-proof
+ technology.
+
With
+ the
+ Vocdoni
+ App,
+ you
+ can
+ create
+ all
+ kinds
+ of
+ voting
+ processes
+ that
+ adapts
+ your
+ needs
+ with
+ the
+ maximum
+ garantees
+ of
+ privacy,
+ verification
+ and
+ in
+ elections
+ and
+ polls
+ with
+ confidence,
+ knowing
+ that
+ your
+ vote
+ is
+ secure
+ and
+ your
+ voice
+ is
+ heard.
+
Here’s
+ how
+ you
+ can
+ get
+ started:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ Explore
+ Features:
+ Dive
+ into
+ the
+ app
+ and
+ discover
+ the
+ range
+ of
+ features
+ designed
+ to
+ make
+ your
+ voting
+ experience
+ seamless
+ and
+ trustworthy.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ Stay
+ Informed:
+ Keep
+ an
+ eye
+ on
+ upcoming
+ elections
+ and
+ polls.
+ We'll
+ notify
+ you
+ about
+ important
+ events
+ and
+ updates.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ Participate:
+ Cast
+ your
+ vote
+ in
+ ongoing
+ elections
+ and
+ see
+ how
+ your
+ participation
+ contributes
+ to
+ a
+ transparent
+ and
+ verifiable
+ process.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ If
+ you
+ have
+ any
+ questions
+ or
+ need
+ assistance,
+ our
+ support
+ team
+ is
+ here
+ to
+ help.
+ Simply
+ reply
+ to
+ this
+ email
+ or
+ visit
+ our
+ Help
+ Center.
+
Thank
+ you
+ for
+ joining
+ Vocdoni.
+ Together,
+ we
+ are
+ paving
+ the
+ way
+ for
+ a
+ more
+ democratic
+ and
+ transparent
+ future.
+
+ Vocdoni is a blockchain-based voting platform designed to
+ ensure secure, transparent, and tamper-proof digital voting.
+ It leverages decentralized technologies to provide scalable
+ and verifiable election processes for organizations and
+ communities. Vocdoni aims to enhance democratic
+ participation through innovative and accessible voting
+ solutions.
Vocdoni
+ Association. All rights reserved
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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() {