Skip to content

Commit

Permalink
refactor email sender to use new templates
Browse files Browse the repository at this point in the history
  • Loading branch information
erudenko committed Jul 5, 2023
1 parent 78fa3a6 commit fd2fb85
Show file tree
Hide file tree
Showing 41 changed files with 406 additions and 502 deletions.
7 changes: 4 additions & 3 deletions api_test/mongo_user_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"testing"

"github.com/madappgang/identifo/v2/l"
"github.com/madappgang/identifo/v2/model"
"github.com/madappgang/identifo/v2/storage/mongo"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -79,12 +80,12 @@ func TestFetchUser(t *testing.T) {

t.Run("user not found", func(t *testing.T) {
_, err := s.UserByID(primitive.NewObjectID().Hex())
require.ErrorIs(t, err, model.ErrUserNotFound)
require.ErrorIs(t, err, l.ErrorUserNotFound)

_, err = s.UserByPhone("+71111111112")
require.ErrorIs(t, err, model.ErrUserNotFound)
require.ErrorIs(t, err, l.ErrorUserNotFound)

_, err = s.UserByEmail("noemail@example.com")
require.ErrorIs(t, err, model.ErrUserNotFound)
require.ErrorIs(t, err, l.ErrorUserNotFound)
})
}
2 changes: 1 addition & 1 deletion config/configurator.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func NewServer(config model.ConfigurationStorage, restartChan chan<- bool) (mode
errs = append(errs, fmt.Errorf("error creating user storage: %v", err))
}

userController := storage.NewUserStorageController(user, settings.SecuritySettings)
userController := storage.NewUserStorageController(user, settings)
l, err := l.NewPrinter(settings.General.Locale)
if err != nil {
log.Printf("Error on Create Localized printer for User Controller %v", err)
Expand Down
36 changes: 15 additions & 21 deletions jwt/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,30 +108,24 @@ func TestNewToken(t *testing.T) {
Email: "username@gmailc.om",
}, "password", "admin", false)
scopes := []string{"scope1", "scope2"}
tokenPayload := []string{"name"}
app := model.AppData{
ID: "123456",
Secret: "1",
Active: true,
Name: "testName",
Description: "testDescriprion",
Scopes: scopes,
Offline: true,
Type: model.Web,
RedirectURLs: []string{},
TokenLifespan: 0,
InviteTokenLifespan: 0,
RefreshTokenLifespan: 0,
TokenPayload: tokenPayload,
TFAStatus: model.TFAStatusDisabled,
DebugTFACode: "",
ID: "123456",
Secret: "1",
Active: true,
Name: "testName",
Description: "testDescriprion",
Scopes: scopes,
Offline: true,
Type: model.Web,
RedirectURLs: []string{},
// TokenLifespan: 0,
// InviteTokenLifespan: 0,
// RefreshTokenLifespan: 0,
// TokenPayload: tokenPayload,
// TFAStatus: model.TFAStatusDisabled,
// DebugTFACode: "",
RegistrationForbidden: false,
AnonymousRegistrationAllowed: true,
AuthzWay: model.NoAuthz,
AuthzModel: "",
AuthzPolicy: "",
RolesWhitelist: []string{},
RolesBlacklist: []string{},
NewUserDefaultRole: "",
}
token, err := tokenService.NewAccessToken(user, scopes, app, false, nil)
Expand Down
4 changes: 0 additions & 4 deletions l/localization.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,3 @@ func LoadDefaultCatalog() error {
})
return err
}

// func Sprintf(lan language.Tag, s string, params ...any) string {

// }
8 changes: 4 additions & 4 deletions l/messages_const.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions l/translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ api.internal_server_error_with_error: Internal server error; %v.
api.access_denied: Access denied.
error.api.user.unable_to_create: Unable to create use, please try again or contact support team.
error.api.verification_code.invalid: Sorry, the code you entered is invalid or has expired. Please get a new one.
error.api.user.not_found: User not found.
error.api.user.not_found.error: User not found with error; %v.
error.api.username.taken: Username is taken. Try to choose another one.
error.api.username_phone_email.taken: Username, email or/and phone is taken. Try to choose another one.
error.api.email.taken: Email is taken. Try to choose another one.
Expand Down Expand Up @@ -97,6 +95,8 @@ error.invalid.phone: "invalid phone number"
error.email.empty: "email could not be empty"
error.phone.empty: "phone could not be empty"
error.username.empty: "username could not be empty"
error.user.not_found: User not found.
error.user.not_found.error: User not found with error; %v.

# Federated login
api.app.federated.provider.not_supported: Federated provider is not supported.
Expand Down Expand Up @@ -183,4 +183,5 @@ password.reject.compromised: "Reject compromised passwords, powered by HaveBeenP
password.require.lowercase: "Require at least one lowercase characters."
password.require.uppercase: "Require at least one uppercase characters."
password.require.number: "Require at least one number."
password.require.symbol: "Require at least special character: !$%%^&*()_+{}:@[];'#<>?,./|\\-=?."
password.require.symbol: "Require at least special character: !$%%^&*()_+{}:@[];'#<>?,./|\\-=?."

10 changes: 8 additions & 2 deletions model/email_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package model

// EmailService manages sending emails.
type EmailService interface {
SendTemplateEmail(emailType EmailTemplateType, subfolder, subject, recipient string, data EmailData) error
SendUserEmail(emailType EmailTemplateType, subfolder string, user User, data any) error
Transport() EmailTransport
Start()
Stop()
Expand All @@ -15,5 +15,11 @@ type EmailTransport interface {

type EmailData struct {
User User
Data interface{}
Data any
}

type ResetEmailData struct {
Token string
URL string
Host string
}
13 changes: 11 additions & 2 deletions model/email_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const (
EmailTemplateTypeResetPassword EmailTemplateType = "reset-password-email"
EmailTemplateTypeTFAWithCode EmailTemplateType = "tfa-code-email"
EmailTemplateTypeVerifyEmail EmailTemplateType = "verify-email"
// EmailTemplateTypeWelcome EmailTemplateType = "welcome-email"
// TODO: Implement welcome email
EmailTemplateTypeWelcome EmailTemplateType = "welcome-email"

DefaultTemplateExtension = "html"
)
Expand All @@ -16,6 +17,14 @@ func (t EmailTemplateType) FileName() string {
return string(t) + "." + DefaultTemplateExtension
}

func (t EmailTemplateType) FileNameWithLocale(locale string) string {
postfix := ""
if len(locale) > 0 {
postfix = "_" + locale
}
return string(t) + "." + DefaultTemplateExtension + postfix
}

func (t EmailTemplateType) String() string {
return string(t)
}
Expand All @@ -26,6 +35,6 @@ func AllEmailTemplatesFileNames() []string {
EmailTemplateTypeResetPassword.FileName(),
EmailTemplateTypeTFAWithCode.FileName(),
EmailTemplateTypeVerifyEmail.FileName(),
// EmailTemplateTypeWelcome.FileName(),
EmailTemplateTypeWelcome.FileName(),
}
}
1 change: 1 addition & 0 deletions model/user_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type UserMutationController interface {

SendEmailConfirmation(ctx context.Context, userID string) error
SendPhoneConfirmation(ctx context.Context, userID string) error
SendPasswordResetEmail(ctx context.Context, userID, appID string) (ResetEmailData, error)

InvalidateCache()
}
3 changes: 1 addition & 2 deletions model/user_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ package model

import (
"context"
"errors"
"regexp"
)

// ErrUserNotFound is when user not found.
var ErrUserNotFound = errors.New("user not found")
// var ErrUserNotFound = errors.New("user not found")

var (
// EmailRegexp is a regexp which all valid emails must match.
Expand Down
14 changes: 0 additions & 14 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package server
import (
"fmt"
"net/http"
"net/url"
"os"

"github.com/madappgang/identifo/v2/model"
"github.com/madappgang/identifo/v2/web"
Expand Down Expand Up @@ -35,17 +33,6 @@ func NewServer(storages model.ServerStorageCollection, services model.ServerServ
errs: errs, // keep the list of errors which keep server invalid but working
}

// env variable can rewrite host option
hostName := os.Getenv("IDENTIFO_HOST_NAME")
if len(hostName) == 0 {
hostName = settings.General.Host
}

host, err := url.ParseRequestURI(hostName)
if err != nil {
return nil, err
}

var originChecker *middleware.AppOriginChecker
if len(errs) == 0 {
// we have valid config loaded and we can do origin checker
Expand All @@ -60,7 +47,6 @@ func NewServer(storages model.ServerStorageCollection, services model.ServerServ
routerSettings := web.RouterSetting{
Server: &s,
ServeAdminPanel: settings.AdminPanel.Enabled,
Host: host,
AppOriginChecker: originChecker,
RestartChan: restartChan,
LoggerSettings: settings.Logger,
Expand Down
51 changes: 41 additions & 10 deletions services/mail/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,33 +67,64 @@ func (es *EmailService) SendHTML(subject, html, recipient string) error {
return es.transport.SendHTML(subject, html, recipient)
}

func (es *EmailService) SendTemplateEmail(emailType model.EmailTemplateType, subfolder, subject string, recipient string, data model.EmailData) error {
type templatePair struct {
body *template.Template
subject *template.Template
}

func (es *EmailService) SendUserEmail(emailType model.EmailTemplateType, subfolder string, user model.User, data any) error {
p := path.Join(es.templatesPath, subfolder, emailType.FileName())
// check template in cache
var tt template.Template
// trying to load user localized version
if len(user.Locale) > 0 {
pl := path.Join(es.templatesPath, subfolder, emailType.FileNameWithLocale(user.Locale))
fi, err := fs.Stat(es.fs, pl)
if err == nil && fi.Mode().IsRegular() {
p = pl
}
}

tp := templatePair{}
tpll, ok := es.cache.Load(p)
if ok {
tt = tpll.(template.Template)
tp = tpll.(templatePair)
} else {
data, err := fs.ReadFile(es.fs, p)
if err != nil {
return err
}
tmpl, err := template.New(p).Parse(string(data))
// extract subject and body from template
subjectText, bodyText, err := extractSubjectAndBody(data)
if err != nil {
return err
}
tp.body, err = template.New(p).Parse(string(bodyText))
if err != nil {
return err
}
tt = *tmpl
es.cache.Store(p, tt)
tp.subject, err = template.New(p).Parse(string(subjectText))
if err != nil {
return err
}

es.cache.Store(p, tp)
es.watcher.AppendForWatching(p) // add for watching to invalidate cache if we need
}

// read template, parse it and send it with underlying service
var tpl bytes.Buffer
if err := tt.Execute(&tpl, data); err != nil {
var subject bytes.Buffer
var body bytes.Buffer
d := map[string]any{
"Data": data,
"User": user,
}
if err := tp.subject.Execute(&subject, d); err != nil {
return err
}
if err := tp.body.Execute(&body, d); err != nil {
return err
}
return es.SendHTML(subject, tpl.String(), recipient)

return es.SendHTML(subject.String(), body.String(), user.Email)
}

func (es *EmailService) Start() {
Expand Down
52 changes: 52 additions & 0 deletions services/mail/template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package mail

import (
"bufio"
"bytes"
"fmt"
)

// extractSubjectAndBody extracts subject and email body from template
// template structure is
// ---
// subject text
// ---
// html body
func extractSubjectAndBody(d []byte) (string, string, error) {
scanner := bufio.NewScanner(bytes.NewReader(d))
scanner.Split(split)
haveSubject := scanner.Scan()
if !haveSubject {
return "", "", fmt.Errorf("no subject") // TODO: localized error
}
subject := scanner.Text()
haveBody := scanner.Scan()
if !haveBody {
return "", "", fmt.Errorf("no body") // TODO: localized error
}
body := scanner.Text()
return subject, body, nil
}

// TODO: write unit tests
func split(data []byte, atEOF bool) (advance int, token []byte, err error) {
dataLen := len(data)

// Return Nothing if at the end of file or no data passed.
if atEOF && dataLen == 0 {
return 0, nil, nil
}

// Find next separator and return token.
if i := bytes.Index(data, []byte("---")); i >= 0 {
return i + 3, data[0:i], nil
}

// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return dataLen, data, nil
}

// Request more data.
return 0, nil, nil
}
Empty file added services/mail/template_test.go
Empty file.
3 changes: 3 additions & 0 deletions static/email_templates/invite-email.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
---
Hello {{Data Username}}
---
<html>
<body>
<h1>Hi!</h1>
Expand Down
3 changes: 2 additions & 1 deletion storage/boltdb/management_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/google/uuid"
"github.com/madappgang/identifo/v2/l"
"github.com/madappgang/identifo/v2/model"
bolt "go.etcd.io/bbolt"
)
Expand Down Expand Up @@ -52,7 +53,7 @@ func (ms *ManagementKeysStorage) GetKey(ctx context.Context, id string) (model.M
b := tx.Bucket([]byte(ManagementKeysBucket))
u := b.Get([]byte(id))
if u == nil {
return model.ErrUserNotFound
return l.ErrorUserNotFound
}

return json.Unmarshal(u, &res)
Expand Down
Loading

0 comments on commit fd2fb85

Please sign in to comment.