Selaa lähdekoodia

add email verification endpoints

Alexander Belanger 4 vuotta sitten
vanhempi
sitoutus
69d0520d72
35 muutettua tiedostoa jossa 631 lisäystä ja 135 poistoa
  1. 39 6
      api/server/authn/handler.go
  2. 41 5
      api/server/authn/handler_test.go
  3. 2 2
      api/server/authn/session_helpers.go
  4. 5 5
      api/server/authz/policy_test.go
  5. 2 2
      api/server/authz/project_test.go
  6. 5 5
      api/server/handlers/project/create_test.go
  7. 1 1
      api/server/handlers/project/get_test.go
  8. 2 2
      api/server/handlers/project/list_test.go
  9. 1 2
      api/server/handlers/user/auth_check_test.go
  10. 1 0
      api/server/handlers/user/cli_login.go
  11. 1 1
      api/server/handlers/user/create_test.go
  12. 2 2
      api/server/handlers/user/delete_test.go
  13. 140 0
      api/server/handlers/user/email_verify.go
  14. 92 0
      api/server/handlers/user/email_verify_test.go
  15. 6 6
      api/server/handlers/user/login_test.go
  16. 2 2
      api/server/handlers/user/logout_test.go
  17. 1 0
      api/server/handlers/user/pw_reset.go
  18. 17 1
      api/server/router/router.go
  19. 43 0
      api/server/router/user.go
  20. 1 1
      api/server/shared/apitest/authn.go
  21. 8 5
      api/server/shared/apitest/config.go
  22. 54 0
      api/server/shared/apitest/notifier.go
  23. 7 0
      api/server/shared/apitest/request.go
  24. 2 2
      api/server/shared/apitest/user.go
  25. 8 2
      api/server/shared/config.go
  26. 21 0
      api/server/shared/reader.go
  27. 4 2
      api/server/shared/requestutils/decoder.go
  28. 6 0
      api/types/email_verify.go
  29. 5 4
      api/types/request.go
  30. 0 3
      internal/auth/sessionstore/sessionstore.go
  31. 30 0
      internal/notifier/notifier.go
  32. 40 33
      internal/notifier/sendgrid/sendgrid.go
  33. 18 1
      server/api/api.go
  34. 8 12
      server/api/invite_handler.go
  35. 16 28
      server/api/user_handler.go

+ 39 - 6
api/server/authn/handler.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
@@ -28,13 +29,21 @@ func NewAuthNFactory(
 // NewAuthenticated creates a new instance of `AuthN` that implements the http.Handler
 // interface.
 func (f *AuthNFactory) NewAuthenticated(next http.Handler) http.Handler {
-	return &AuthN{next, f.config}
+	return &AuthN{next, f.config, false}
+}
+
+// NewAuthenticatedWithRedirect creates a new instance of `AuthN` that implements the http.Handler
+// interface. This handler redirects the user to login if the user is not attached, and stores a
+// redirect URI in the session, if the session exists.
+func (f *AuthNFactory) NewAuthenticatedWithRedirect(next http.Handler) http.Handler {
+	return &AuthN{next, f.config, true}
 }
 
 // AuthN implements the authentication middleware
 type AuthN struct {
-	next   http.Handler
-	config *shared.Config
+	next     http.Handler
+	config   *shared.Config
+	redirect bool
 }
 
 // ServeHTTP attaches an authenticated subject to the request context,
@@ -58,7 +67,7 @@ func (authn *AuthN) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// if the bearer token is not found, look for a request cookie
-	session, err := authn.config.Store.Get(r, authn.config.CookieName)
+	session, err := authn.config.Store.Get(r, authn.config.ServerConf.CookieName)
 
 	if err != nil {
 		session.Values["authenticated"] = false
@@ -72,7 +81,7 @@ func (authn *AuthN) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if auth, ok := session.Values["authenticated"].(bool); !auth || !ok {
-		authn.sendForbiddenError(fmt.Errorf("stored cookie was not authenticated"), w)
+		authn.handleForbiddenForSession(w, r, fmt.Errorf("stored cookie was not authenticated"), session)
 		return
 	}
 
@@ -80,13 +89,37 @@ func (authn *AuthN) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	userID, ok := session.Values["user_id"].(uint)
 
 	if !ok {
-		authn.sendForbiddenError(fmt.Errorf("could not cast user_id to uint"), w)
+		authn.handleForbiddenForSession(w, r, fmt.Errorf("could not cast user_id to uint"), session)
 		return
 	}
 
 	authn.nextWithUserID(w, r, userID)
 }
 
+func (authn *AuthN) handleForbiddenForSession(
+	w http.ResponseWriter,
+	r *http.Request,
+	err error,
+	session *sessions.Session,
+) {
+	if authn.redirect {
+		// need state parameter to validate when redirected
+		if r.URL.RawQuery == "" {
+			session.Values["redirect"] = r.URL.Path
+		} else {
+			session.Values["redirect"] = r.URL.Path + "?" + r.URL.RawQuery
+		}
+
+		session.Save(r, w)
+
+		http.Redirect(w, r, "/dashboard", 302)
+	} else {
+		authn.sendForbiddenError(err, w)
+	}
+
+	return
+}
+
 // nextWithToken calls the next handler with either the service account or user corresponding
 // to the token set in context.
 func (authn *AuthN) nextWithToken(w http.ResponseWriter, r *http.Request, tok *token.Token) {

+ 41 - 5
api/server/authn/handler_test.go

@@ -27,7 +27,7 @@ func TestAuthenticatedUserWithCookie(t *testing.T) {
 	rr := httptest.NewRecorder()
 
 	// create a new user and a cookie for them
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	cookie := apitest.AuthenticateUserWithCookie(t, config, user, false)
 	req.AddCookie(cookie)
 
@@ -53,6 +53,31 @@ func TestUnauthenticatedUserWithCookie(t *testing.T) {
 	assertForbiddenError(t, next, rr)
 }
 
+func TestUnauthenticatedUserWithCookieRedirect(t *testing.T) {
+	_, handler, next := loadHandlersWithRedirect(t)
+
+	req, err := http.NewRequest("GET", "/auth-endpoint", nil)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	// make the request without a cookie set
+	handler.ServeHTTP(rr, req)
+
+	assert.Equal(t, http.StatusFound, rr.Result().StatusCode)
+	gotLoc, err := rr.Result().Location()
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assert.Equal(t, "/dashboard", gotLoc.Path)
+	assert.False(t, next.WasCalled, "next handler should not have been called")
+}
+
 func TestAuthenticatedUserWithToken(t *testing.T) {
 	config, handler, next := loadHandlers(t)
 
@@ -65,7 +90,7 @@ func TestAuthenticatedUserWithToken(t *testing.T) {
 	rr := httptest.NewRecorder()
 
 	// create a new user for the token to reference
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	tokenStr := apitest.AuthenticateUserWithToken(t, config, user.ID)
 	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tokenStr))
 
@@ -106,7 +131,7 @@ func TestAuthBadDatabaseRead(t *testing.T) {
 	rr := httptest.NewRecorder()
 
 	// create a new user and a cookie for them
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	cookie := apitest.AuthenticateUserWithCookie(t, config, user, false)
 	req.AddCookie(cookie)
 
@@ -133,7 +158,7 @@ func TestAuthBadSessionUserWrite(t *testing.T) {
 	rr := httptest.NewRecorder()
 
 	// create a new user and a cookie for them
-	apitest.CreateTestUser(t, config)
+	apitest.CreateTestUser(t, config, true)
 
 	// create cookie where session values are incorrect
 	// i.e. written for a user that doesn't exist (id 500)
@@ -161,7 +186,7 @@ func TestAuthBadSessionUserIDType(t *testing.T) {
 	rr := httptest.NewRecorder()
 
 	// create a new user and a cookie for them
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 
 	// create cookie where session values are incorrect
 	// i.e. written for a user that doesn't exist (id 500)
@@ -197,6 +222,17 @@ func loadHandlers(t *testing.T) (*shared.Config, http.Handler, *testHandler) {
 	return config, handler, next
 }
 
+func loadHandlersWithRedirect(t *testing.T) (*shared.Config, http.Handler, *testHandler) {
+	config := apitest.LoadConfig(t)
+
+	factory := authn.NewAuthNFactory(config)
+
+	next := &testHandler{}
+	handler := factory.NewAuthenticatedWithRedirect(next)
+
+	return config, handler, next
+}
+
 func assertForbiddenError(t *testing.T, next *testHandler, rr *httptest.ResponseRecorder) {
 	assert := assert.New(t)
 

+ 2 - 2
api/server/authn/session_helpers.go

@@ -13,7 +13,7 @@ func SaveUserAuthenticated(
 	config *shared.Config,
 	user *models.User,
 ) error {
-	session, err := config.Store.Get(r, config.CookieName)
+	session, err := config.Store.Get(r, config.ServerConf.CookieName)
 
 	if err != nil {
 		return err
@@ -38,7 +38,7 @@ func SaveUserUnauthenticated(
 	r *http.Request,
 	config *shared.Config,
 ) error {
-	session, err := config.Store.Get(r, config.CookieName)
+	session, err := config.Store.Get(r, config.ServerConf.CookieName)
 
 	if err != nil {
 		return err

+ 5 - 5
api/server/authz/policy_test.go

@@ -27,7 +27,7 @@ func TestPolicyMiddlewareSuccessfulProjectCluster(t *testing.T) {
 		},
 	}, false, false)
 
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	_, err := project.CreateProjectWithUser(config, &models.Project{
 		Name: "test-project",
 	}, user)
@@ -75,7 +75,7 @@ func TestPolicyMiddlewareSuccessfulApplication(t *testing.T) {
 		},
 	}, false, false)
 
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	_, err := project.CreateProjectWithUser(config, &models.Project{
 		Name: "test-project",
 	}, user)
@@ -140,7 +140,7 @@ func TestPolicyMiddlewareInvalidPermissions(t *testing.T) {
 		},
 	}, false, true)
 
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	_, err := project.CreateProjectWithUser(config, &models.Project{
 		Name: "test-project",
 	}, user)
@@ -174,7 +174,7 @@ func TestPolicyMiddlewareFailInvalidLoader(t *testing.T) {
 		},
 	}, true, false)
 
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	_, err := project.CreateProjectWithUser(config, &models.Project{
 		Name: "test-project",
 	}, user)
@@ -207,7 +207,7 @@ func TestPolicyMiddlewareFailBadParam(t *testing.T) {
 		},
 	}, true, false)
 
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	_, err := project.CreateProjectWithUser(config, &models.Project{
 		Name: "test-project",
 	}, user)

+ 2 - 2
api/server/authz/project_test.go

@@ -18,7 +18,7 @@ import (
 func TestProjectMiddlewareSuccessful(t *testing.T) {
 	config, handler, next := loadProjectHandlers(t)
 
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	proj, err := project.CreateProjectWithUser(config, &models.Project{
 		Name: "test-project",
 	}, user)
@@ -46,7 +46,7 @@ func TestProjectMiddlewareSuccessful(t *testing.T) {
 func TestProjectMiddlewareFailedRead(t *testing.T) {
 	config, _, _ := loadProjectHandlers(t)
 
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	_, err := project.CreateProjectWithUser(config, &models.Project{
 		Name: "test-project",
 	}, user)

+ 5 - 5
api/server/handlers/project/create_test.go

@@ -21,7 +21,7 @@ func TestCreateProjectSuccessful(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t)
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	req = apitest.WithAuthenticatedUser(t, req, user)
 
 	handler := project.NewProjectCreateHandler(
@@ -60,7 +60,7 @@ func TestFailingDecoderValidator(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t)
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	req = apitest.WithAuthenticatedUser(t, req, user)
 
 	handler := project.NewProjectCreateHandler(
@@ -85,7 +85,7 @@ func TestFailingCreateMethod(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t, test.CreateProjectMethod)
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	req = apitest.WithAuthenticatedUser(t, req, user)
 
 	handler := project.NewProjectCreateHandler(
@@ -110,7 +110,7 @@ func TestFailingCreateRoleMethod(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t, test.CreateProjectRoleMethod)
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	req = apitest.WithAuthenticatedUser(t, req, user)
 
 	handler := project.NewProjectCreateHandler(
@@ -135,7 +135,7 @@ func TestFailingReadMethod(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t, test.ReadProjectMethod)
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	req = apitest.WithAuthenticatedUser(t, req, user)
 
 	handler := project.NewProjectCreateHandler(

+ 1 - 1
api/server/handlers/project/get_test.go

@@ -13,7 +13,7 @@ import (
 func TestGetProjectSuccessful(t *testing.T) {
 	// create a test project
 	config := apitest.LoadConfig(t)
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	proj, err := project.CreateProjectWithUser(config, &models.Project{
 		Name: "test-project",
 	}, user)

+ 2 - 2
api/server/handlers/project/list_test.go

@@ -14,7 +14,7 @@ import (
 func TestListProjectsSuccessful(t *testing.T) {
 	// create a test project
 	config := apitest.LoadConfig(t)
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	proj1, err := project.CreateProjectWithUser(config, &models.Project{
 		Name: "test-project",
 	}, user)
@@ -60,7 +60,7 @@ func TestFailingListMethod(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t, test.ListProjectsByUserIDMethod)
-	user := apitest.CreateTestUser(t, config)
+	user := apitest.CreateTestUser(t, config, true)
 	req = apitest.WithAuthenticatedUser(t, req, user)
 
 	handler := project.NewProjectListHandler(

+ 1 - 2
api/server/handlers/user/auth_check_test.go

@@ -10,9 +10,8 @@ import (
 )
 
 func TestAuthCheckSuccessful(t *testing.T) {
-	// create a test project
 	config := apitest.LoadConfig(t)
-	authUser := apitest.CreateTestUser(t, config)
+	authUser := apitest.CreateTestUser(t, config, true)
 	req, rr := apitest.GetRequestAndRecorder(t, string(types.HTTPVerbPost), "/api/auth/check", nil)
 
 	req = apitest.WithAuthenticatedUser(t, req, authUser)

+ 1 - 0
api/server/handlers/user/cli_login.go

@@ -0,0 +1 @@
+package user

+ 1 - 1
api/server/handlers/user/create_test.go

@@ -109,7 +109,7 @@ func TestCreateUserSameEmail(t *testing.T) {
 	config := apitest.LoadConfig(t)
 
 	// create the existing user
-	apitest.CreateTestUser(t, config)
+	apitest.CreateTestUser(t, config, true)
 
 	handler := user.NewUserCreateHandler(
 		config,

+ 2 - 2
api/server/handlers/user/delete_test.go

@@ -21,7 +21,7 @@ func TestDeleteUserSuccessful(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t)
-	authUser := apitest.CreateTestUser(t, config)
+	authUser := apitest.CreateTestUser(t, config, true)
 	req = apitest.WithAuthenticatedUser(t, req, authUser)
 
 	handler := user.NewUserDeleteHandler(
@@ -58,7 +58,7 @@ func TestFailingDeleteUserMethod(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t, test.DeleteUserMethod)
-	authUser := apitest.CreateTestUser(t, config)
+	authUser := apitest.CreateTestUser(t, config, true)
 	req = apitest.WithAuthenticatedUser(t, req, authUser)
 
 	handler := user.NewUserDeleteHandler(

+ 140 - 0
api/server/handlers/user/email_verify.go

@@ -0,0 +1,140 @@
+package user
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
+	"golang.org/x/crypto/bcrypt"
+)
+
+type VerifyEmailInitiateHandler struct {
+	config *shared.Config
+}
+
+func NewVerifyEmailInitiateHandler(
+	config *shared.Config,
+) *VerifyEmailInitiateHandler {
+	return &VerifyEmailInitiateHandler{config}
+}
+
+func (v *VerifyEmailInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	pwReset, rawToken, err := CreateTokenForEmail(v.config, user.Email)
+
+	if err != nil {
+		apierrors.HandleAPIError(w, v.config.Logger, apierrors.NewErrInternal(err))
+		return
+	}
+
+	queryVals := url.Values{
+		"token":    []string{rawToken},
+		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
+	}
+
+	err = v.config.UserNotifier.SendEmailVerification(
+		&notifier.SendEmailVerificationOpts{
+			Email: user.Email,
+			URL:   fmt.Sprintf("%s/api/email/verify/finalize?%s", v.config.ServerConf.ServerURL, queryVals.Encode()),
+		},
+	)
+
+	if err != nil {
+		apierrors.HandleAPIError(w, v.config.Logger, apierrors.NewErrInternal(err))
+		return
+	}
+}
+
+type VerifyEmailFinalizeHandler struct {
+	config           *shared.Config
+	decoderValidator shared.RequestDecoderValidator
+}
+
+func NewVerifyEmailFinalizeHandler(
+	config *shared.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) *VerifyEmailFinalizeHandler {
+	return &VerifyEmailFinalizeHandler{config, decoderValidator}
+}
+
+func (v *VerifyEmailFinalizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	request := &types.VerifyEmailFinalizeRequest{}
+
+	if err := v.decoderValidator.DecodeAndValidateNoWrite(r, request); err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape(err.Error()), 302)
+		return
+	}
+
+	// verify the token is valid
+	token, err := v.config.Repo.PWResetToken().ReadPWResetToken(request.TokenID)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Email verification error: valid token required"), 302)
+		return
+	}
+
+	// make sure the token is still valid and has not expired
+	if !token.IsValid || token.IsExpired() {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Email verification error: valid token required"), 302)
+		return
+	}
+
+	// make sure the token is correct
+	if err := bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(request.Token)); err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Email verification error: valid token required"), 302)
+		return
+	}
+
+	user.EmailVerified = true
+
+	user, err = v.config.Repo.User().UpdateUser(user)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Could not verify email address"), 302)
+		return
+	}
+
+	// invalidate the token
+	token.IsValid = false
+
+	_, err = v.config.Repo.PWResetToken().UpdatePWResetToken(token)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Could not verify email address"), 302)
+		return
+	}
+
+	http.Redirect(w, r, "/dashboard", 302)
+	return
+}
+
+func CreateTokenForEmail(config *shared.Config, email string) (*models.PWResetToken, string, error) {
+	form := &forms.InitiateResetUserPasswordForm{
+		Email: email,
+	}
+
+	// convert the form to a pw reset token model
+	pwReset, rawToken, err := form.ToPWResetToken()
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	// handle write to the database
+	pwReset, err = config.Repo.PWResetToken().CreatePWResetToken(pwReset)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	return pwReset, rawToken, nil
+}

+ 92 - 0
api/server/handlers/user/email_verify_test.go

@@ -0,0 +1,92 @@
+package user_test
+
+import (
+	"fmt"
+	"net/url"
+	"testing"
+
+	"github.com/porter-dev/porter/api/server/handlers/user"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apitest"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/stretchr/testify/assert"
+	"golang.org/x/crypto/bcrypt"
+)
+
+func TestEmailVerifyInitiateSuccessful(t *testing.T) {
+	config := apitest.LoadConfig(t)
+	authUser := apitest.CreateTestUser(t, config, true)
+	req, rr := apitest.GetRequestAndRecorder(t, string(types.HTTPVerbPost), "/api/email/verify/initiate", nil)
+
+	req = apitest.WithAuthenticatedUser(t, req, authUser)
+
+	handler := user.NewVerifyEmailInitiateHandler(config)
+
+	handler.ServeHTTP(rr, req)
+
+	// check the notifier data from config by casting to a fake notifier object
+	fakeNotifier, ok := config.UserNotifier.(*apitest.FakeUserNotifier)
+
+	if !ok {
+		t.Fatal("Could not cast user notifier to fake notifier")
+	}
+
+	initiateOpts := fakeNotifier.GetSendEmailVerificationLastOpts()
+	assert.Equal(t, "test@test.it", initiateOpts.Email)
+
+	// parse the url and compare
+	parsedURL, err := url.Parse(initiateOpts.URL)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// read token from the DB
+	token, err := config.Repo.PWResetToken().ReadPWResetToken(1)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assert.Equal(t, "/api/email/verify/finalize", parsedURL.Path)
+	vals := parsedURL.Query()
+
+	assert.True(t, bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(vals["token"][0])) == nil)
+	assert.Equal(t, "1", vals["token_id"][0])
+}
+
+func TestEmailVerifyFinalizeSuccessful(t *testing.T) {
+	config := apitest.LoadConfig(t)
+	authUser := apitest.CreateTestUser(t, config, false)
+
+	// create a token in the DB to use for testing
+	pwReset, rawToken, err := user.CreateTokenForEmail(config, authUser.Email)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, rr := apitest.GetRequestAndRecorder(
+		t,
+		string(types.HTTPVerbGet),
+		"/api/email/verify/finalize?"+url.Values{
+			"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
+			"token":    []string{rawToken},
+		}.Encode(),
+		nil,
+	)
+
+	req = apitest.WithAuthenticatedUser(t, req, authUser)
+
+	handler := user.NewVerifyEmailFinalizeHandler(
+		config,
+		shared.NewDefaultRequestDecoderValidator(config),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	// read the user and check that their email has been verified
+	authUser, err = config.Repo.User().ReadUser(authUser.ID)
+
+	assert.True(t, authUser.EmailVerified)
+}

+ 6 - 6
api/server/handlers/user/login_test.go

@@ -24,7 +24,7 @@ func TestLoginUserSuccessful(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t)
-	apitest.CreateTestUser(t, config)
+	apitest.CreateTestUser(t, config, true)
 
 	handler := user.NewUserLoginHandler(
 		config,
@@ -57,7 +57,7 @@ func TestLoginUserIncorrectPassword(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t)
-	apitest.CreateTestUser(t, config)
+	apitest.CreateTestUser(t, config, true)
 
 	handler := user.NewUserLoginHandler(
 		config,
@@ -84,7 +84,7 @@ func TestLoginUserBadEmail(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t)
-	apitest.CreateTestUser(t, config)
+	apitest.CreateTestUser(t, config, true)
 
 	handler := user.NewUserLoginHandler(
 		config,
@@ -111,7 +111,7 @@ func TestLoginUserEmptyPassword(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t)
-	apitest.CreateTestUser(t, config)
+	apitest.CreateTestUser(t, config, true)
 
 	handler := user.NewUserLoginHandler(
 		config,
@@ -138,7 +138,7 @@ func TestLoginUserNotExist(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t)
-	apitest.CreateTestUser(t, config)
+	apitest.CreateTestUser(t, config, true)
 
 	handler := user.NewUserLoginHandler(
 		config,
@@ -163,7 +163,7 @@ func TestLoginUserFailingReadUserByEmailMethod(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t, test.ReadUserByEmailMethod)
-	apitest.CreateTestUser(t, config)
+	apitest.CreateTestUser(t, config, true)
 
 	handler := user.NewUserLoginHandler(
 		config,

+ 2 - 2
api/server/handlers/user/logout_test.go

@@ -19,7 +19,7 @@ func TestLogoutUserSuccessful(t *testing.T) {
 	)
 
 	config := apitest.LoadConfig(t)
-	authUser := apitest.CreateTestUser(t, config)
+	authUser := apitest.CreateTestUser(t, config, true)
 	apitest.WithAuthenticatedUser(t, req, authUser)
 
 	handler := user.NewUserLogoutHandler(config)
@@ -29,7 +29,7 @@ func TestLogoutUserSuccessful(t *testing.T) {
 	assert.Equal(t, http.StatusOK, rr.Result().StatusCode, "status code should be 200")
 
 	// read the session to make sure "authenticated" is false
-	session, err := config.Store.Get(req, config.CookieName)
+	session, err := config.Store.Get(req, config.ServerConf.CookieName)
 
 	if err != nil {
 		t.Fatal(err)

+ 1 - 0
api/server/handlers/user/pw_reset.go

@@ -0,0 +1 @@
+package user

+ 17 - 1
api/server/router/router.go

@@ -13,6 +13,9 @@ import (
 func NewAPIRouter(config *shared.Config) *chi.Mux {
 	r := chi.NewRouter()
 
+	// set the content type for all API endpoints
+	r.Use(ContentTypeJSON)
+
 	endpointFactory := shared.NewAPIObjectEndpointFactory(config)
 	baseRegisterer := NewBaseRegisterer()
 	projRegisterer := NewProjectScopedRegisterer()
@@ -79,7 +82,12 @@ func registerRoutes(config *shared.Config, routes []*Route) {
 		for _, scope := range route.Endpoint.Metadata.Scopes {
 			switch scope {
 			case types.UserScope:
-				atomicGroup.Use(authNFactory.NewAuthenticated)
+				// if the endpoint should redirect when authn fails, attach redirect handler
+				if route.Endpoint.Metadata.ShouldRedirect {
+					atomicGroup.Use(authNFactory.NewAuthenticatedWithRedirect)
+				} else {
+					atomicGroup.Use(authNFactory.NewAuthenticated)
+				}
 			case types.ProjectScope:
 				atomicGroup.Use(projFactory.Middleware)
 			}
@@ -92,3 +100,11 @@ func registerRoutes(config *shared.Config, routes []*Route) {
 		)
 	}
 }
+
+// ContentTypeJSON sets the content type for requests to application/json
+func ContentTypeJSON(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json;charset=utf8")
+		next.ServeHTTP(w, r)
+	})
+}

+ 43 - 0
api/server/router/user.go

@@ -161,5 +161,48 @@ func getUserRoutes(
 		Router:   r,
 	})
 
+	// GET /email/verify/initiate -> user.VerifyEmailInitiateHandler
+	emailVerifyInitiateEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/email/verify/initiate",
+			},
+			Scopes: []types.PermissionScope{types.UserScope},
+		},
+	)
+
+	emailVerifyInitiateHandler := user.NewVerifyEmailInitiateHandler(config)
+
+	routes = append(routes, &Route{
+		Endpoint: emailVerifyInitiateEndpoint,
+		Handler:  emailVerifyInitiateHandler,
+		Router:   r,
+	})
+
+	// GET /email/verify/finalize -> user.VerifyEmailInitiateHandler
+	emailVerifyFinalizeEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/email/verify/finalize",
+			},
+			Scopes:         []types.PermissionScope{types.UserScope},
+			ShouldRedirect: true,
+		},
+	)
+
+	emailVerifyFinalizeHandler := user.NewVerifyEmailInitiateHandler(config)
+
+	routes = append(routes, &Route{
+		Endpoint: emailVerifyFinalizeEndpoint,
+		Handler:  emailVerifyFinalizeHandler,
+		Router:   r,
+	})
+
 	return routes
 }

+ 1 - 1
api/server/shared/apitest/authn.go

@@ -28,7 +28,7 @@ func AuthenticateUserWithCookie(
 	}
 
 	// set the user as authenticated
-	session, err := config.Store.Get(req2, config.CookieName)
+	session, err := config.Store.Get(req2, config.ServerConf.CookieName)
 
 	if err != nil {
 		t.Fatal(err)

+ 8 - 5
api/server/shared/apitest/config.go

@@ -35,12 +35,15 @@ func (t *TestConfigLoader) LoadConfig() (*shared.Config, error) {
 		TokenSecret: configFromEnv.Server.TokenGeneratorSecret,
 	}
 
+	notifier := NewFakeUserNotifier()
+
 	return &shared.Config{
-		Logger:     l,
-		Repo:       repo,
-		Store:      store,
-		CookieName: configFromEnv.Server.CookieName,
-		TokenConf:  tokenConf,
+		Logger:       l,
+		Repo:         repo,
+		Store:        store,
+		ServerConf:   configFromEnv.Server,
+		TokenConf:    tokenConf,
+		UserNotifier: notifier,
 	}, nil
 }
 

+ 54 - 0
api/server/shared/apitest/notifier.go

@@ -0,0 +1,54 @@
+package apitest
+
+import (
+	"github.com/porter-dev/porter/internal/notifier"
+)
+
+// FakeUserNotifier just stores data about a single notification,
+// without sending the data anywhere
+type FakeUserNotifier struct {
+	lastPWResetOpts  *notifier.SendPasswordResetEmailOpts
+	lastGHResetOpts  *notifier.SendGithubRelinkEmailOpts
+	lastEmailVerOpts *notifier.SendEmailVerificationOpts
+	lastProjInvOpts  *notifier.SendProjectInviteEmailOpts
+}
+
+func NewFakeUserNotifier() notifier.UserNotifier {
+	return &FakeUserNotifier{}
+}
+
+func (f *FakeUserNotifier) SendPasswordResetEmail(opts *notifier.SendPasswordResetEmailOpts) error {
+	f.lastPWResetOpts = opts
+	return nil
+}
+
+func (f *FakeUserNotifier) GetPasswordResetEmailLastOpts() *notifier.SendPasswordResetEmailOpts {
+	return f.lastPWResetOpts
+}
+
+func (f *FakeUserNotifier) SendGithubRelinkEmail(opts *notifier.SendGithubRelinkEmailOpts) error {
+	f.lastGHResetOpts = opts
+	return nil
+}
+
+func (f *FakeUserNotifier) GetGithubRelinkEmailLastOpts() *notifier.SendGithubRelinkEmailOpts {
+	return f.lastGHResetOpts
+}
+
+func (f *FakeUserNotifier) SendEmailVerification(opts *notifier.SendEmailVerificationOpts) error {
+	f.lastEmailVerOpts = opts
+	return nil
+}
+
+func (f *FakeUserNotifier) GetSendEmailVerificationLastOpts() *notifier.SendEmailVerificationOpts {
+	return f.lastEmailVerOpts
+}
+
+func (f *FakeUserNotifier) SendProjectInviteEmail(opts *notifier.SendProjectInviteEmailOpts) error {
+	f.lastProjInvOpts = opts
+	return nil
+}
+
+func (f *FakeUserNotifier) GetSendProjectInviteEmailLastOpts() *notifier.SendProjectInviteEmailOpts {
+	return f.lastProjInvOpts
+}

+ 7 - 0
api/server/shared/apitest/request.go

@@ -68,6 +68,13 @@ func (f *failingDecoderValidator) DecodeAndValidate(
 	return false
 }
 
+func (f *failingDecoderValidator) DecodeAndValidateNoWrite(
+	r *http.Request,
+	v interface{},
+) error {
+	return fmt.Errorf("fake error")
+}
+
 func NewFailingDecoderValidator(config *shared.Config) shared.RequestDecoderValidator {
 	return &failingDecoderValidator{config}
 }

+ 2 - 2
api/server/shared/apitest/user.go

@@ -8,13 +8,13 @@ import (
 	"golang.org/x/crypto/bcrypt"
 )
 
-func CreateTestUser(t *testing.T, config *shared.Config) *models.User {
+func CreateTestUser(t *testing.T, config *shared.Config, verified bool) *models.User {
 	hashedPw, _ := bcrypt.GenerateFromPassword([]byte("hello"), 8)
 
 	user, err := config.Repo.User().CreateUser(&models.User{
 		Email:         "test@test.it",
 		Password:      string(hashedPw),
-		EmailVerified: true,
+		EmailVerified: verified,
 	})
 
 	if err != nil {

+ 8 - 2
api/server/shared/config.go

@@ -3,7 +3,9 @@ package shared
 import (
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
@@ -21,11 +23,15 @@ type Config struct {
 	// Store implements a session store for session-based cookies
 	Store sessions.Store
 
-	// CookieName is the name of the Porter cookie used for authn
-	CookieName string
+	// ServerConf is the set of configuration variables for the Porter server
+	ServerConf config.ServerConf
 
 	// TokenConf contains the config for generating and validating JWT tokens
 	TokenConf *token.TokenGeneratorConf
+
+	// UserNotifier is an object that notifies users of transactions (pw reset, email
+	// verification, etc)
+	UserNotifier notifier.UserNotifier
 }
 
 type ConfigLoader interface {

+ 21 - 0
api/server/shared/reader.go

@@ -1,6 +1,7 @@
 package shared
 
 import (
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
@@ -9,6 +10,7 @@ import (
 
 type RequestDecoderValidator interface {
 	DecodeAndValidate(w http.ResponseWriter, r *http.Request, v interface{}) bool
+	DecodeAndValidateNoWrite(r *http.Request, v interface{}) error
 }
 
 type DefaultRequestDecoderValidator struct {
@@ -47,3 +49,22 @@ func (j *DefaultRequestDecoderValidator) DecodeAndValidate(
 
 	return true
 }
+
+func (j *DefaultRequestDecoderValidator) DecodeAndValidateNoWrite(
+	r *http.Request,
+	v interface{},
+) error {
+	var requestErr apierrors.RequestError
+
+	// decode the request parameters (body and query)
+	if requestErr = j.decoder.Decode(v, r); requestErr != nil {
+		return fmt.Errorf(requestErr.InternalError())
+	}
+
+	// validate the request object
+	if requestErr = j.validator.Validate(v); requestErr != nil {
+		return fmt.Errorf(requestErr.InternalError())
+	}
+
+	return nil
+}

+ 4 - 2
api/server/shared/requestutils/decoder.go

@@ -51,8 +51,10 @@ func (d *DefaultDecoder) Decode(
 
 	// decode into the request object
 	// a nil body is not a fatal error
-	if err := json.NewDecoder(r.Body).Decode(s); err != nil && !errors.Is(err, io.EOF) {
-		return requestErrorFromJSONErr(err)
+	if r.Body != nil {
+		if err := json.NewDecoder(r.Body).Decode(s); err != nil && !errors.Is(err, io.EOF) {
+			return requestErrorFromJSONErr(err)
+		}
 	}
 
 	return nil

+ 6 - 0
api/types/email_verify.go

@@ -0,0 +1,6 @@
+package types
+
+type VerifyEmailFinalizeRequest struct {
+	TokenID uint   `schema:"token_id" form:"required"`
+	Token   string `schema:"token" form:"required"`
+}

+ 5 - 4
api/types/request.go

@@ -45,8 +45,9 @@ type Path struct {
 }
 
 type APIRequestMetadata struct {
-	Verb   APIVerb
-	Method HTTPVerb
-	Path   *Path
-	Scopes []PermissionScope
+	Verb           APIVerb
+	Method         HTTPVerb
+	Path           *Path
+	Scopes         []PermissionScope
+	ShouldRedirect bool
 }

+ 0 - 3
internal/auth/sessionstore/sessionstore.go

@@ -172,9 +172,6 @@ func (store *PGStore) New(r *http.Request, name string) (*sessions.Session, erro
 				} else if strings.Contains(err.Error(), "expired timestamp") {
 					err = nil
 					session.IsNew = false
-				} else {
-					// if error is not record not found, db read is failing, so return fatal err
-					return nil, err
 				}
 			} else {
 				session.IsNew = false

+ 30 - 0
internal/notifier/notifier.go

@@ -0,0 +1,30 @@
+package notifier
+
+type SendPasswordResetEmailOpts struct {
+	Email string
+	URL   string
+}
+
+type SendGithubRelinkEmailOpts struct {
+	Email string
+	URL   string
+}
+
+type SendEmailVerificationOpts struct {
+	Email string
+	URL   string
+}
+
+type SendProjectInviteEmailOpts struct {
+	InviteeEmail      string
+	URL               string
+	Project           string
+	ProjectOwnerEmail string
+}
+
+type UserNotifier interface {
+	SendPasswordResetEmail(opts *SendPasswordResetEmailOpts) error
+	SendGithubRelinkEmail(opts *SendGithubRelinkEmailOpts) error
+	SendEmailVerification(opts *SendEmailVerificationOpts) error
+	SendProjectInviteEmail(opts *SendProjectInviteEmailOpts) error
+}

+ 40 - 33
internal/integrations/email/sendgrid.go → internal/notifier/sendgrid/sendgrid.go

@@ -1,13 +1,16 @@
-package email
+package sendgrid
 
 import (
-	"os"
-
+	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/sendgrid/sendgrid-go"
 	"github.com/sendgrid/sendgrid-go/helpers/mail"
 )
 
-type SendgridClient struct {
+type UserNotifier struct {
+	client *Client
+}
+
+type Client struct {
 	APIKey                  string
 	PWResetTemplateID       string
 	PWGHTemplateID          string
@@ -16,8 +19,12 @@ type SendgridClient struct {
 	SenderEmail             string
 }
 
-func (client *SendgridClient) SendPWResetEmail(url, email string) error {
-	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+func NewUserNotifier(client *Client) notifier.UserNotifier {
+	return &UserNotifier{client}
+}
+
+func (s *UserNotifier) SendPasswordResetEmail(opts *notifier.SendPasswordResetEmailOpts) error {
+	request := sendgrid.GetRequest(s.client.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
 	request.Method = "POST"
 
 	sgMail := &mail.SGMailV3{
@@ -25,20 +32,20 @@ func (client *SendgridClient) SendPWResetEmail(url, email string) error {
 			{
 				To: []*mail.Email{
 					{
-						Address: email,
+						Address: opts.Email,
 					},
 				},
 				DynamicTemplateData: map[string]interface{}{
-					"url":   url,
-					"email": email,
+					"url":   opts.URL,
+					"email": opts.Email,
 				},
 			},
 		},
 		From: &mail.Email{
-			Address: client.SenderEmail,
+			Address: s.client.SenderEmail,
 			Name:    "Porter",
 		},
-		TemplateID: client.PWResetTemplateID,
+		TemplateID: s.client.PWResetTemplateID,
 	}
 
 	request.Body = mail.GetRequestBody(sgMail)
@@ -48,8 +55,8 @@ func (client *SendgridClient) SendPWResetEmail(url, email string) error {
 	return err
 }
 
-func (client *SendgridClient) SendGHPWEmail(url, email string) error {
-	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+func (s *UserNotifier) SendGithubRelinkEmail(opts *notifier.SendGithubRelinkEmailOpts) error {
+	request := sendgrid.GetRequest(s.client.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
 	request.Method = "POST"
 
 	sgMail := &mail.SGMailV3{
@@ -57,20 +64,20 @@ func (client *SendgridClient) SendGHPWEmail(url, email string) error {
 			{
 				To: []*mail.Email{
 					{
-						Address: email,
+						Address: opts.Email,
 					},
 				},
 				DynamicTemplateData: map[string]interface{}{
-					"url":   url,
-					"email": email,
+					"url":   opts.URL,
+					"email": opts.Email,
 				},
 			},
 		},
 		From: &mail.Email{
-			Address: client.SenderEmail,
+			Address: s.client.SenderEmail,
 			Name:    "Porter",
 		},
-		TemplateID: client.PWGHTemplateID,
+		TemplateID: s.client.PWGHTemplateID,
 	}
 
 	request.Body = mail.GetRequestBody(sgMail)
@@ -80,8 +87,8 @@ func (client *SendgridClient) SendGHPWEmail(url, email string) error {
 	return err
 }
 
-func (client *SendgridClient) SendEmailVerification(url, email string) error {
-	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+func (s *UserNotifier) SendEmailVerification(opts *notifier.SendEmailVerificationOpts) error {
+	request := sendgrid.GetRequest(s.client.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
 	request.Method = "POST"
 
 	sgMail := &mail.SGMailV3{
@@ -89,20 +96,20 @@ func (client *SendgridClient) SendEmailVerification(url, email string) error {
 			{
 				To: []*mail.Email{
 					{
-						Address: email,
+						Address: opts.Email,
 					},
 				},
 				DynamicTemplateData: map[string]interface{}{
-					"url":   url,
-					"email": email,
+					"url":   opts.URL,
+					"email": opts.Email,
 				},
 			},
 		},
 		From: &mail.Email{
-			Address: client.SenderEmail,
+			Address: s.client.SenderEmail,
 			Name:    "Porter",
 		},
-		TemplateID: client.VerifyEmailTemplateID,
+		TemplateID: s.client.VerifyEmailTemplateID,
 	}
 
 	request.Body = mail.GetRequestBody(sgMail)
@@ -112,8 +119,8 @@ func (client *SendgridClient) SendEmailVerification(url, email string) error {
 	return err
 }
 
-func (client *SendgridClient) SendProjectInviteEmail(url, project, projectOwnerEmail, email string) error {
-	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+func (s *UserNotifier) SendProjectInviteEmail(opts *notifier.SendProjectInviteEmailOpts) error {
+	request := sendgrid.GetRequest(s.client.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
 	request.Method = "POST"
 
 	sgMail := &mail.SGMailV3{
@@ -121,21 +128,21 @@ func (client *SendgridClient) SendProjectInviteEmail(url, project, projectOwnerE
 			{
 				To: []*mail.Email{
 					{
-						Address: email,
+						Address: opts.InviteeEmail,
 					},
 				},
 				DynamicTemplateData: map[string]interface{}{
-					"url":          url,
-					"sender_email": projectOwnerEmail,
-					"project":      project,
+					"url":          opts.URL,
+					"sender_email": opts.ProjectOwnerEmail,
+					"project":      opts.Project,
 				},
 			},
 		},
 		From: &mail.Email{
-			Address: client.SenderEmail,
+			Address: s.client.SenderEmail,
 			Name:    "Porter",
 		},
-		TemplateID: client.ProjectInviteTemplateID,
+		TemplateID: s.client.ProjectInviteTemplateID,
 	}
 
 	request.Body = mail.GetRequestBody(sgMail)

+ 18 - 1
server/api/api.go

@@ -10,6 +10,8 @@ import (
 	vr "github.com/go-playground/validator/v10"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/notifier/sendgrid"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
@@ -89,6 +91,7 @@ type App struct {
 	translator    *ut.Translator
 	tokenConf     *token.TokenGeneratorConf
 	segmentClient *segment.Client
+	notifier      notifier.UserNotifier
 }
 
 type AppCapabilities struct {
@@ -201,7 +204,21 @@ func New(conf *AppConfig) (*App, error) {
 		})
 	}
 
-	app.Capabilities.Email = sc.SendgridAPIKey != ""
+	if sc.SendgridAPIKey != "" {
+		app.Capabilities.Email = true
+
+		sgClient := &sendgrid.Client{
+			APIKey:                  sc.SendgridAPIKey,
+			PWResetTemplateID:       sc.SendgridPWResetTemplateID,
+			PWGHTemplateID:          sc.SendgridPWGHTemplateID,
+			VerifyEmailTemplateID:   sc.SendgridVerifyEmailTemplateID,
+			ProjectInviteTemplateID: sc.SendgridProjectInviteTemplateID,
+			SenderEmail:             sc.SendgridSenderEmail,
+		}
+
+		app.notifier = sendgrid.NewUserNotifier(sgClient)
+	}
+
 	app.Capabilities.Analytics = sc.SegmentClientKey != ""
 	app.Capabilities.BasicLogin = sc.BasicLoginEnabled
 

+ 8 - 12
server/api/invite_handler.go

@@ -10,8 +10,8 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/forms"
-	"github.com/porter-dev/porter/internal/integrations/email"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
 )
 
 // HandleCreateInvite creates a new invite for a project
@@ -85,17 +85,13 @@ func (app *App) HandleCreateInvite(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	sgClient := email.SendgridClient{
-		APIKey:                  app.ServerConf.SendgridAPIKey,
-		ProjectInviteTemplateID: app.ServerConf.SendgridProjectInviteTemplateID,
-		SenderEmail:             app.ServerConf.SendgridSenderEmail,
-	}
-
-	sgClient.SendProjectInviteEmail(
-		fmt.Sprintf("%s/api/projects/%d/invites/%s", app.ServerConf.ServerURL, projID, invite.Token),
-		project.Name,
-		user.Email,
-		form.Email,
+	app.notifier.SendProjectInviteEmail(
+		&notifier.SendProjectInviteEmailOpts{
+			InviteeEmail:      form.Email,
+			URL:               fmt.Sprintf("%s/api/projects/%d/invites/%s", app.ServerConf.ServerURL, projID, invite.Token),
+			Project:           project.Name,
+			ProjectOwnerEmail: user.Email,
+		},
 	)
 }
 

+ 16 - 28
server/api/user_handler.go

@@ -17,8 +17,8 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/forms"
-	"github.com/porter-dev/porter/internal/integrations/email"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/repository"
 	segment "gopkg.in/segmentio/analytics-go.v3"
 )
@@ -427,15 +427,11 @@ func (app *App) InitiateEmailVerifyUser(w http.ResponseWriter, r *http.Request)
 		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
 	}
 
-	sgClient := email.SendgridClient{
-		APIKey:                app.ServerConf.SendgridAPIKey,
-		VerifyEmailTemplateID: app.ServerConf.SendgridVerifyEmailTemplateID,
-		SenderEmail:           app.ServerConf.SendgridSenderEmail,
-	}
-
-	err = sgClient.SendEmailVerification(
-		fmt.Sprintf("%s/api/email/verify/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
-		form.Email,
+	err = app.notifier.SendEmailVerification(
+		&notifier.SendEmailVerificationOpts{
+			Email: user.Email,
+			URL:   fmt.Sprintf("%s/api/email/verify/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
+		},
 	)
 
 	if err != nil {
@@ -568,15 +564,11 @@ func (app *App) InitiatePWResetUser(w http.ResponseWriter, r *http.Request) {
 
 	// if the user is a Github user, send them a Github email
 	if user.GithubUserID != 0 {
-		sgClient := email.SendgridClient{
-			APIKey:         app.ServerConf.SendgridAPIKey,
-			PWGHTemplateID: app.ServerConf.SendgridPWGHTemplateID,
-			SenderEmail:    app.ServerConf.SendgridSenderEmail,
-		}
-
-		err = sgClient.SendGHPWEmail(
-			fmt.Sprintf("%s/api/oauth/login/github", app.ServerConf.ServerURL),
-			form.Email,
+		err := app.notifier.SendGithubRelinkEmail(
+			&notifier.SendGithubRelinkEmailOpts{
+				Email: user.Email,
+				URL:   fmt.Sprintf("%s/api/oauth/login/github", app.ServerConf.ServerURL),
+			},
 		)
 
 		if err != nil {
@@ -610,15 +602,11 @@ func (app *App) InitiatePWResetUser(w http.ResponseWriter, r *http.Request) {
 		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
 	}
 
-	sgClient := email.SendgridClient{
-		APIKey:            app.ServerConf.SendgridAPIKey,
-		PWResetTemplateID: app.ServerConf.SendgridPWResetTemplateID,
-		SenderEmail:       app.ServerConf.SendgridSenderEmail,
-	}
-
-	err = sgClient.SendPWResetEmail(
-		fmt.Sprintf("%s/password/reset/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
-		form.Email,
+	err = app.notifier.SendPasswordResetEmail(
+		&notifier.SendPasswordResetEmailOpts{
+			Email: user.Email,
+			URL:   fmt.Sprintf("%s/password/reset/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
+		},
 	)
 
 	if err != nil {