diff --git a/email/mailgun_test.go b/email/mailgun_test.go index 6c78dd91..3c299b69 100644 --- a/email/mailgun_test.go +++ b/email/mailgun_test.go @@ -16,7 +16,6 @@ func TestNewEmailConfigFromReader(t *testing.T) { { json: `{"type":"mailgun","id":"mg","privateAPIKey":"private","publicAPIKey":"public","domain":"example.com"}`, want: MailgunEmailerConfig{ - ID: "mg", PrivateAPIKey: "private", PublicAPIKey: "public", Domain: "example.com", diff --git a/email/template.go b/email/template.go index 2f3c3e03..d992eded 100644 --- a/email/template.go +++ b/email/template.go @@ -36,6 +36,11 @@ type TemplatizedEmailer struct { textTemplates *template.Template htmlTemplates *htmltemplate.Template emailer Emailer + globalCtx map[string]interface{} +} + +func (t *TemplatizedEmailer) SetGlobalContext(ctx map[string]interface{}) { + t.globalCtx = ctx } // SendMail queues an email to be sent to a recipient. @@ -59,6 +64,10 @@ func (t *TemplatizedEmailer) SendMail(from, subject, tplName string, data map[st data["from"] = from data["subject"] = subject + for k, v := range t.globalCtx { + data[k] = v + } + var textBuffer bytes.Buffer if textTpl != nil { err := textTpl.Execute(&textBuffer, data) diff --git a/email/template_test.go b/email/template_test.go index a4a37cad..59644172 100644 --- a/email/template_test.go +++ b/email/template_test.go @@ -9,9 +9,11 @@ import ( const ( textTemplateString = `{{define "T1.txt"}}{{.gift}} from {{.from}} to {{.to}}.{{end}} {{define "T3.txt"}}Hello there, {{.name}}!{{end}} +{{define "T4.txt"}}Hello there, {{.name}}! Welcome to {{.planet}}!{{end}} ` htmlTemplateString = `{{define "T1.html"}}{{.gift}} from {{.from}} to {{.to}}.{{end}} {{define "T2.html"}}Hello, {{.name}}!{{end}} +{{define "T4.html"}}Hello there, {{.name}}! Welcome to {{.planet}}!{{end}} ` ) @@ -51,6 +53,7 @@ func TestTemplatizedEmailSendMail(t *testing.T) { wantText string wantHtml string wantErr bool + ctx map[string]interface{} }{ { tplName: "T1", @@ -97,11 +100,29 @@ func TestTemplatizedEmailSendMail(t *testing.T) { wantText: "", wantHtml: htmlStart + "Hello, Alice<script>alert('hacked!')</script>!" + htmlEnd, }, + { + tplName: "T4", + from: "bob@example.com", + to: "alice@example.com", + subject: "hello there", + data: map[string]interface{}{ + "name": "Alice", + }, + wantText: "Hello there, Alice! Welcome to Mars!", + ctx: map[string]interface{}{ + "planet": "Mars", + }, + wantHtml: "Hello there, Alice! Welcome to Mars!", + }, } for i, tt := range tests { emailer := &testEmailer{} templatizer := NewTemplatizedEmailerFromTemplates(textTemplates, htmlTemplates, emailer) + if tt.ctx != nil { + templatizer.SetGlobalContext(tt.ctx) + } + err := templatizer.SendMail(tt.from, tt.subject, tt.tplName, tt.data, tt.to) if tt.wantErr { if err == nil { diff --git a/integration/user_api_test.go b/integration/user_api_test.go index 7c321d12..0b4c1431 100644 --- a/integration/user_api_test.go +++ b/integration/user_api_test.go @@ -519,6 +519,7 @@ func TestCreateUser(t *testing.T) { cantEmail: tt.cantEmail, lastEmail: tt.req.User.Email, lastClientID: "XXX", + lastWasInvite: true, lastRedirectURL: *urlParsed, } if diff := pretty.Compare(wantEmalier, f.emailer); diff != "" { @@ -578,6 +579,7 @@ type testEmailer struct { lastEmail string lastClientID string lastRedirectURL url.URL + lastWasInvite bool } // SendResetPasswordEmail returns resetPasswordURL when it can't email, mimicking the behavior of the real UserEmailer. @@ -585,6 +587,20 @@ func (t *testEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, t.lastEmail = email t.lastRedirectURL = redirectURL t.lastClientID = clientID + t.lastWasInvite = false + + var retURL *url.URL + if t.cantEmail { + retURL = &testResetPasswordURL + } + return retURL, nil +} + +func (t *testEmailer) SendInviteEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) { + t.lastEmail = email + t.lastRedirectURL = redirectURL + t.lastClientID = clientID + t.lastWasInvite = true var retURL *url.URL if t.cantEmail { diff --git a/server/config.go b/server/config.go index 08eb318c..9e1e742b 100644 --- a/server/config.go +++ b/server/config.go @@ -85,7 +85,7 @@ func (cfg *ServerConfig) Server() (*Server, error) { return nil, err } - err = setEmailer(&srv, cfg.EmailFromAddress, cfg.EmailerConfigFile, cfg.EmailTemplateDirs) + err = setEmailer(&srv, cfg.IssuerName, cfg.EmailFromAddress, cfg.EmailerConfigFile, cfg.EmailTemplateDirs) if err != nil { return nil, err } @@ -238,7 +238,7 @@ func setTemplates(srv *Server, tpls *template.Template) error { return nil } -func setEmailer(srv *Server, fromAddress, emailerConfigFile string, emailTemplateDirs []string) error { +func setEmailer(srv *Server, issuerName, fromAddress, emailerConfigFile string, emailTemplateDirs []string) error { cfg, err := email.NewEmailerConfigFromFile(emailerConfigFile) if err != nil { @@ -290,6 +290,9 @@ func setEmailer(srv *Server, fromAddress, emailerConfigFile string, emailTemplat } } tMailer := email.NewTemplatizedEmailerFromTemplates(textTemplates, htmlTemplates, emailer) + tMailer.SetGlobalContext(map[string]interface{}{ + "issuer_name": issuerName, + }) ue := useremail.NewUserEmailer(srv.UserRepo, srv.PasswordInfoRepo, diff --git a/server/password_test.go b/server/password_test.go index 4c0e02ce..c4dfbca5 100644 --- a/server/password_test.go +++ b/server/password_test.go @@ -102,7 +102,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) { wantEmailer: &testEmailer{ to: str("Email-1@example.com"), from: "noreply@example.com", - subject: "Reset your password.", + subject: "Reset Your Password", }, wantPRUserID: "ID-1", wantPRRedirect: &testRedirectURL, @@ -138,7 +138,7 @@ func TestSendResetPasswordEmailHandler(t *testing.T) { wantEmailer: &testEmailer{ to: str("Email-1@example.com"), from: "noreply@example.com", - subject: "Reset your password.", + subject: "Reset Your Password", }, wantPRPassword: "password", wantPRUserID: "ID-1", diff --git a/static/email/invite.html b/static/email/invite.html new file mode 100644 index 00000000..a9bdd7e0 --- /dev/null +++ b/static/email/invite.html @@ -0,0 +1,7 @@ + + + Welcome to Dex! Click below to set your password: + + Set Password + + diff --git a/static/email/invite.txt b/static/email/invite.txt new file mode 100644 index 00000000..02259718 --- /dev/null +++ b/static/email/invite.txt @@ -0,0 +1,4 @@ +Welcome to Dex! Click below to set your password: + +Link: +{{ .link }} diff --git a/test b/test index 7116d8ef..3d26c7ba 100755 --- a/test +++ b/test @@ -14,7 +14,7 @@ COVER=${COVER:-"-cover"} source ./build -TESTABLE="connector db integration pkg/crypto pkg/flag pkg/http pkg/net pkg/time pkg/html functional/repo server session user user/api" +TESTABLE="connector db integration pkg/crypto pkg/flag pkg/http pkg/net pkg/time pkg/html functional/repo server session user user/api email" FORMATTABLE="$TESTABLE cmd/dexctl cmd/dex-worker cmd/dex-overlord examples/app functional pkg/log" # user has not provided PKG override diff --git a/user/api/api.go b/user/api/api.go index a05cc746..8c27173e 100644 --- a/user/api/api.go +++ b/user/api/api.go @@ -89,6 +89,7 @@ type UsersAPI struct { type Emailer interface { SendResetPasswordEmail(string, url.URL, string) (*url.URL, error) + SendInviteEmail(string, url.URL, string) (*url.URL, error) } type Creds struct { @@ -169,7 +170,7 @@ func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (s usr = userToSchemaUser(userUser) - url, err := u.emailer.SendResetPasswordEmail(usr.Email, validRedirURL, creds.ClientID) + url, err := u.emailer.SendInviteEmail(usr.Email, validRedirURL, creds.ClientID) // An email is sent only if we don't get a link and there's no error. emailSent := err == nil && url == nil diff --git a/user/api/api_test.go b/user/api/api_test.go index fa5f85b1..f6b3255a 100644 --- a/user/api/api_test.go +++ b/user/api/api_test.go @@ -20,13 +20,23 @@ type testEmailer struct { lastEmail string lastClientID string lastRedirectURL url.URL + lastWasInvite bool } // SendResetPasswordEmail returns resetPasswordURL when it can't email, mimicking the behavior of the real UserEmailer. func (t *testEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) { + return t.sendEmail(email, redirectURL, clientID, false) +} + +func (t *testEmailer) SendInviteEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) { + return t.sendEmail(email, redirectURL, clientID, true) +} + +func (t *testEmailer) sendEmail(email string, redirectURL url.URL, clientID string, invite bool) (*url.URL, error) { t.lastEmail = email t.lastRedirectURL = redirectURL t.lastClientID = clientID + t.lastWasInvite = invite var retURL *url.URL if t.cantEmail { @@ -369,6 +379,7 @@ func TestCreateUser(t *testing.T) { lastEmail: tt.usr.Email, lastClientID: tt.creds.ClientID, lastRedirectURL: tt.redirURL, + lastWasInvite: true, } if diff := pretty.Compare(wantEmalier, emailer); diff != "" { t.Errorf("case %d: Compare(want, got) = %v", i, diff --git a/user/email/email.go b/user/email/email.go index 9203327d..2d671167 100644 --- a/user/email/email.go +++ b/user/email/email.go @@ -53,6 +53,16 @@ func NewUserEmailer(ur user.UserRepo, // This method DOES NOT check for client ID, redirect URL validity - it is expected that upstream users have already done so. // If there is no emailer is configured, the URL of the aforementioned link is returned, otherwise nil is returned. func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) { + return u.sendResetPasswordOrInviteEmail(email, redirectURL, clientID, false) +} + +// SendInviteEmail is exactly the same as SendResetPasswordEmail, except that it uses the invite template and subject name. +// In the near future, invite emails might diverge further. +func (u *UserEmailer) SendInviteEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) { + return u.sendResetPasswordOrInviteEmail(email, redirectURL, clientID, true) +} + +func (u *UserEmailer) sendResetPasswordOrInviteEmail(email string, redirectURL url.URL, clientID string, invite bool) (*url.URL, error) { usr, err := u.ur.GetByEmail(nil, email) if err == user.ErrorNotFound { log.Errorf("No Such user for email: %q", email) @@ -95,8 +105,17 @@ func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, q.Set("token", token) resetURL.RawQuery = q.Encode() + var tmplName, subj string + if invite { + tmplName = "invite" + subj = "Activate Your Account" + } else { + tmplName = "password-reset" + subj = "Reset Your Password" + } + if u.emailer != nil { - err = u.emailer.SendMail(u.fromAddress, "Reset your password.", "password-reset", + err = u.emailer.SendMail(u.fromAddress, subj, tmplName, map[string]interface{}{ "email": usr.Email, "link": resetURL.String(), @@ -107,6 +126,7 @@ func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, return nil, err } return &resetURL, nil + } // SendEmailVerification sends an email to the user with the given userID containing a link which when visited marks the user as having had their email verified.