Skip to content

Commit 79c8c55

Browse files
authored
feat: Implement SMTP email service client (#141)
* feat: Implement SMTP email service client * chore: Apply review suggestions
1 parent 73f7359 commit 79c8c55

File tree

15 files changed

+500
-15
lines changed

15 files changed

+500
-15
lines changed

app.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,14 @@ MONGO_USER=hs_auth
1515
MONGO_PASSWORD=password123
1616

1717
JWT_SECRET="very secret phrase"
18+
19+
# Only required if AppConfig is configured to use SendGrid
20+
# as the email delivery service
1821
SENDGRID_API_KEY=sumkey
22+
23+
# Only required if AppConfig is configured to use SMTP
24+
# as the email delivery service
25+
SMTP_USERNAME=username
26+
SMTP_PASSWORD=password
27+
SMTP_HOST=smtp.service
28+
SMTP_PORT=1111

config/base.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Store non-sensitive project configuration here
22
name: "Hacker Suite - Auth"
33
email:
4+
email_delivery_provider: "smtp"
45
noreply_email_addr: "[email protected]"
56
help_email_addr: "[email protected]"
67
noreply_email_name: "UniCS"

config/config.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"github.com/unicsmcr/hs_auth/config/role"
55
"github.com/unicsmcr/hs_auth/environment"
6+
"github.com/unicsmcr/hs_auth/services/multiplexers/types"
67
"go.uber.org/config"
78
)
89

@@ -16,12 +17,14 @@ var (
1617

1718
// EmailConfig stores the configuration to be used by the email service
1819
type EmailConfig struct {
19-
HelpEmailAddr string `yaml:"help_email_addr"`
20-
NoreplyEmailAddr string `yaml:"noreply_email_addr"`
21-
NoreplyEmailName string `yaml:"noreply_email_name"`
22-
EmailVerificationEmailSubj string `yaml:"email_verification_email_subj"`
23-
PasswordResetEmailSubj string `yaml:"password_reset_email_subj"`
24-
TokenLifetime int64 `yaml:"token_lifetime"`
20+
// Specifies what service is used for email delivery. Currently supported options: smtp, sendgrid
21+
EmailDeliveryProvider types.EmailDeliveryProvider `yaml:"email_delivery_provider"`
22+
HelpEmailAddr string `yaml:"help_email_addr"`
23+
NoreplyEmailAddr string `yaml:"noreply_email_addr"`
24+
NoreplyEmailName string `yaml:"noreply_email_name"`
25+
EmailVerificationEmailSubj string `yaml:"email_verification_email_subj"`
26+
PasswordResetEmailSubj string `yaml:"password_reset_email_subj"`
27+
TokenLifetime int64 `yaml:"token_lifetime"`
2528
}
2629

2730
// AuthConfig stores the configuration to be used by the auth system V2

config/development.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ name: "Hacker Suite - Auth (dev)"
33
app_url: "localhost:8000"
44
domain_name: "localhost"
55
use_secure_cookies: false
6+
email:
7+
email_delivery_provider: "sendgrid"
68
auth:
79
default_role: "applicant"
810
email_verification_required: false

environment/environment.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const (
1919
MongoPassword = "MONGO_PASSWORD"
2020
JWTSecret = "JWT_SECRET"
2121
SendgridAPIKey = "SENDGRID_API_KEY"
22+
SMTPUsername = "SMTP_USERNAME"
23+
SMTPPassword = "SMTP_PASSWORD"
24+
SMTPHost = "SMTP_HOST"
25+
SMTPPort = "SMTP_PORT"
2226
)
2327

2428
// NewEnv creates an Env with loaded environment variables
@@ -33,6 +37,10 @@ func NewEnv(logger *zap.Logger) *Env {
3337
MongoPassword: valueOfEnvVar(logger, MongoPassword),
3438
JWTSecret: valueOfEnvVar(logger, JWTSecret),
3539
SendgridAPIKey: valueOfEnvVar(logger, SendgridAPIKey),
40+
SMTPUsername: valueOfEnvVar(logger, SMTPUsername),
41+
SMTPPassword: valueOfEnvVar(logger, SMTPPassword),
42+
SMTPHost: valueOfEnvVar(logger, SMTPHost),
43+
SMTPPort: valueOfEnvVar(logger, SMTPPort),
3644
},
3745
}
3846
return &env

environment/environment_test.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ func Test_NewEnv__should_return_correct_env(t *testing.T) {
1818
MongoPassword: "testmongopassword",
1919
JWTSecret: "testsecret",
2020
SendgridAPIKey: "testkey",
21+
SMTPUsername: "testsmtpusername",
22+
SMTPPassword: "testsmtppassword",
23+
SMTPHost: "testsmtphost",
24+
SMTPPort: "testsmtpport",
2125
}
2226

2327
restoreVars := testutils.SetEnvVars(vars)
@@ -41,6 +45,10 @@ func Test_Get__should_return_correct_value(t *testing.T) {
4145
MongoPassword: "testmongopassword",
4246
JWTSecret: "testsecret",
4347
SendgridAPIKey: "testkey",
48+
SMTPUsername: "testsmtpusername",
49+
SMTPPassword: "testsmtppassword",
50+
SMTPHost: "testsmtphost",
51+
SMTPPort: "testsmtpport",
4452
},
4553
}
4654

@@ -85,9 +93,29 @@ func Test_Get__should_return_correct_value(t *testing.T) {
8593
args: JWTSecret,
8694
},
8795
{
88-
name: JWTSecret,
89-
want: env.vars[JWTSecret],
90-
args: JWTSecret,
96+
name: SendgridAPIKey,
97+
want: env.vars[SendgridAPIKey],
98+
args: SendgridAPIKey,
99+
},
100+
{
101+
name: SMTPUsername,
102+
want: env.vars[SMTPUsername],
103+
args: SMTPUsername,
104+
},
105+
{
106+
name: SMTPPassword,
107+
want: env.vars[SMTPPassword],
108+
args: SMTPPassword,
109+
},
110+
{
111+
name: SMTPHost,
112+
want: env.vars[SMTPHost],
113+
args: SMTPHost,
114+
},
115+
{
116+
name: SMTPPort,
117+
want: env.vars[SMTPPort],
118+
args: SMTPPort,
91119
},
92120
}
93121

services/multiplexers/multiplexers.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package multiplexers
2+
3+
import (
4+
"fmt"
5+
"github.com/pkg/errors"
6+
sendgridgo "github.com/sendgrid/sendgrid-go"
7+
authV2 "github.com/unicsmcr/hs_auth/authorization/v2"
8+
"github.com/unicsmcr/hs_auth/config"
9+
"github.com/unicsmcr/hs_auth/environment"
10+
"github.com/unicsmcr/hs_auth/services"
11+
"github.com/unicsmcr/hs_auth/services/multiplexers/types"
12+
v2 "github.com/unicsmcr/hs_auth/services/sendgrid/v2"
13+
smtplib "github.com/unicsmcr/hs_auth/services/smtp"
14+
"github.com/unicsmcr/hs_auth/utils"
15+
)
16+
17+
// NewEmailServiceV2 returns an email service client that uses either Sendgrid or SMTP for email delivery.
18+
// The email delivery service is specified by the AppConfig.Email.EmailDeliveryProvider field
19+
func NewEmailServiceV2(cfg *config.AppConfig, env *environment.Env, smtpClient utils.SMTPClient,
20+
sendGridClient *sendgridgo.Client, userService services.UserService, authorizer authV2.Authorizer,
21+
timeProvider utils.TimeProvider) (services.EmailServiceV2, error) {
22+
switch cfg.Email.EmailDeliveryProvider {
23+
case types.SMTP:
24+
return smtplib.NewSMPTEmailService(cfg, env, smtpClient, userService, authorizer, timeProvider)
25+
case types.SendGrid:
26+
return v2.NewSendgridEmailServiceV2(cfg, env, sendGridClient, userService, authorizer, timeProvider)
27+
default:
28+
return nil, errors.New(fmt.Sprintf("email delivery provider %s is invalid", cfg.Email.EmailDeliveryProvider))
29+
}
30+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package multiplexers
2+
3+
import (
4+
"github.com/stretchr/testify/assert"
5+
"github.com/unicsmcr/hs_auth/config"
6+
"testing"
7+
)
8+
9+
func Test_NewEmailServiceV2__returns_err_when_email_delivery_provider_not_valid(t *testing.T) {
10+
_, err := NewEmailServiceV2(&config.AppConfig{
11+
Email: config.EmailConfig{
12+
EmailDeliveryProvider: "invalid provider",
13+
},
14+
}, nil, nil, nil, nil, nil, nil)
15+
16+
assert.Error(t, err)
17+
}

services/multiplexers/types/types.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package types
2+
3+
type EmailDeliveryProvider string
4+
5+
const (
6+
SMTP EmailDeliveryProvider = "smtp"
7+
SendGrid EmailDeliveryProvider = "sendgrid"
8+
)

services/smtp/smtpEmailService.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package smtp
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"github.com/pkg/errors"
8+
authV2 "github.com/unicsmcr/hs_auth/authorization/v2"
9+
"github.com/unicsmcr/hs_auth/authorization/v2/common"
10+
"github.com/unicsmcr/hs_auth/config"
11+
"github.com/unicsmcr/hs_auth/entities"
12+
"github.com/unicsmcr/hs_auth/environment"
13+
"github.com/unicsmcr/hs_auth/services"
14+
"github.com/unicsmcr/hs_auth/utils"
15+
"html/template"
16+
"net/smtp"
17+
)
18+
19+
var (
20+
passwordResetEmailTemplatePath = "templates/emails/passwordReset_email.gohtml"
21+
emailVerifyEmailTemplatePath = "templates/emails/emailVerify_email.gohtml"
22+
htmlEmailTemplateStr = `From: %s <%s>
23+
To: %s <%s>
24+
Subject: %s
25+
Mime-Version: 1.0;
26+
Content-Type: text/html; charset="UTF-8";
27+
Content-Transfer-Encoding: 8bit;
28+
29+
%s
30+
`
31+
)
32+
33+
type emailBodyTemplateDataModel struct {
34+
EventName string
35+
Link string
36+
SenderName string
37+
}
38+
39+
type smtpEmailService struct {
40+
cfg *config.AppConfig
41+
env *environment.Env
42+
client utils.SMTPClient
43+
userService services.UserService
44+
authorizer authV2.Authorizer
45+
timeProvider utils.TimeProvider
46+
47+
smtpAuth smtp.Auth
48+
passwordResetEmailBodyTemplate *template.Template
49+
emailVerifyEmailBodyTemplate *template.Template
50+
}
51+
52+
func NewSMPTEmailService(cfg *config.AppConfig, env *environment.Env, client utils.SMTPClient,
53+
userService services.UserService, authorizer authV2.Authorizer,
54+
timeProvider utils.TimeProvider) (services.EmailServiceV2, error) {
55+
passwordResetEmailTemplate, err := utils.LoadTemplate("password reset", passwordResetEmailTemplatePath)
56+
if err != nil {
57+
return nil, errors.Wrap(err, "could not load password reset template")
58+
}
59+
60+
emailVerifyEmailTemplate, err := utils.LoadTemplate("email verify", emailVerifyEmailTemplatePath)
61+
if err != nil {
62+
return nil, errors.Wrap(err, "could not load email verify template")
63+
}
64+
65+
return &smtpEmailService{
66+
cfg: cfg,
67+
env: env,
68+
client: client,
69+
userService: userService,
70+
passwordResetEmailBodyTemplate: passwordResetEmailTemplate,
71+
emailVerifyEmailBodyTemplate: emailVerifyEmailTemplate,
72+
authorizer: authorizer,
73+
timeProvider: timeProvider,
74+
smtpAuth: smtp.PlainAuth("", env.Get(environment.SMTPUsername),
75+
env.Get(environment.SMTPPassword), env.Get(environment.SMTPHost)),
76+
}, nil
77+
}
78+
79+
func (s *smtpEmailService) SendEmail(subject, htmlBody, plainTextBody, senderName, senderEmail, recipientName, recipientEmail string) error {
80+
message := fmt.Sprintf(htmlEmailTemplateStr, senderName, senderEmail, recipientName, recipientEmail, subject, htmlBody)
81+
82+
err := s.client.SendEmail(fmt.Sprintf("%s:%s", s.env.Get(environment.SMTPHost), s.env.Get(environment.SMTPPort)),
83+
s.smtpAuth, senderEmail, []string{recipientEmail}, []byte(message))
84+
if err != nil {
85+
return errors.Wrap(err, "could not send email")
86+
}
87+
88+
return nil
89+
}
90+
func (s *smtpEmailService) SendEmailVerificationEmail(ctx context.Context, user entities.User, emailVerificationResources common.UniformResourceIdentifiers) error {
91+
// TODO: the emails should use tokens of type "email" (https://github.com/unicsmcr/hs_auth/issues/121)
92+
emailToken, err := s.authorizer.CreateServiceToken(ctx, user.ID,
93+
emailVerificationResources, s.timeProvider.Now().Unix()+s.cfg.Email.TokenLifetime)
94+
if err != nil {
95+
return errors.Wrap(err, "could not create auth token for email")
96+
}
97+
98+
verificationURL := fmt.Sprintf("http://%s/verifyemail?token=%s&userId=%s", s.cfg.AppURL, emailToken, user.ID.Hex())
99+
100+
var contentBuff bytes.Buffer
101+
err = s.emailVerifyEmailBodyTemplate.Execute(&contentBuff, emailBodyTemplateDataModel{
102+
EventName: s.cfg.Name,
103+
Link: verificationURL,
104+
SenderName: s.cfg.Email.NoreplyEmailName,
105+
})
106+
if err != nil {
107+
return errors.Wrap(err, "could not construct email")
108+
}
109+
110+
return s.SendEmail(
111+
s.cfg.Email.EmailVerificationEmailSubj,
112+
contentBuff.String(),
113+
"",
114+
s.cfg.Email.NoreplyEmailName,
115+
s.cfg.Email.NoreplyEmailAddr,
116+
user.Name,
117+
user.Email)
118+
}
119+
120+
func (s *smtpEmailService) SendPasswordResetEmail(ctx context.Context, user entities.User, passwordResetResources common.UniformResourceIdentifiers) error {
121+
// TODO: the emails should use tokens of type "email" (https://github.com/unicsmcr/hs_auth/issues/1210
122+
emailToken, err := s.authorizer.CreateServiceToken(ctx, user.ID,
123+
passwordResetResources, s.timeProvider.Now().Unix()+s.cfg.Email.TokenLifetime)
124+
if err != nil {
125+
return errors.Wrap(err, "could not create auth token for email")
126+
}
127+
128+
resetURL := fmt.Sprintf("http://%s/resetpwd?token=%s&userId=%s", s.cfg.AppURL, emailToken, user.ID.Hex())
129+
130+
var contentBuff bytes.Buffer
131+
err = s.passwordResetEmailBodyTemplate.Execute(&contentBuff, emailBodyTemplateDataModel{
132+
Link: resetURL,
133+
SenderName: s.cfg.Email.NoreplyEmailName,
134+
})
135+
if err != nil {
136+
return errors.Wrap(err, "could not construct email")
137+
}
138+
139+
return s.SendEmail(
140+
s.cfg.Email.PasswordResetEmailSubj,
141+
contentBuff.String(),
142+
"",
143+
s.cfg.Email.NoreplyEmailName,
144+
s.cfg.Email.NoreplyEmailAddr,
145+
user.Name,
146+
user.Email)
147+
}

0 commit comments

Comments
 (0)