From bcda048eab799284fc46d74706334bf9ef76dc83 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Thu, 6 Jun 2024 15:43:25 +0200
Subject: [feature] Self-serve email change for users (#2957)
* [feature] Email change
* frontend stuff for changing email
* docs
* tests etc
* differentiate more clearly between local user+account and account
* populate user
---
 internal/api/activitypub/users/userget_test.go  |   2 +-
 internal/api/client/accounts/accountcreate.go   |   6 +-
 internal/api/client/accounts/accountdelete.go   |   2 +-
 internal/api/client/admin/accountapprove.go     |   2 +-
 internal/api/client/admin/accountreject.go      |   2 +-
 internal/api/client/user/emailchange.go         | 104 +++++++++++++++++
 internal/api/client/user/emailchange_test.go    | 142 ++++++++++++++++++++++++
 internal/api/client/user/passwordchange_test.go | 122 ++++++--------------
 internal/api/client/user/user.go                |   4 +
 internal/api/client/user/user_test.go           |  43 +++++--
 internal/api/client/user/userget.go             |  78 +++++++++++++
 internal/api/model/user.go                      |  61 ++++++++++
 12 files changed, 468 insertions(+), 100 deletions(-)
 create mode 100644 internal/api/client/user/emailchange.go
 create mode 100644 internal/api/client/user/emailchange_test.go
 create mode 100644 internal/api/client/user/userget.go
(limited to 'internal/api')
diff --git a/internal/api/activitypub/users/userget_test.go b/internal/api/activitypub/users/userget_test.go
index ac8b2c0eb..2a68b309c 100644
--- a/internal/api/activitypub/users/userget_test.go
+++ b/internal/api/activitypub/users/userget_test.go
@@ -97,7 +97,7 @@ func (suite *UserGetTestSuite) TestGetUserPublicKeyDeleted() {
 	userModule := users.New(suite.processor)
 	targetAccount := suite.testAccounts["local_account_1"]
 
-	suite.processor.Account().DeleteSelf(context.Background(), suite.testAccounts["local_account_1"])
+	suite.processor.User().DeleteSelf(context.Background(), suite.testAccounts["local_account_1"])
 
 	// wait for the account delete to be processed
 	if !testrig.WaitFor(func() bool {
diff --git a/internal/api/client/accounts/accountcreate.go b/internal/api/client/accounts/accountcreate.go
index 920b6d4d8..33d743791 100644
--- a/internal/api/client/accounts/accountcreate.go
+++ b/internal/api/client/accounts/accountcreate.go
@@ -105,9 +105,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
 	}
 	form.IP = signUpIP
 
-	// Create the new account + user.
+	// Create the new user+account.
 	ctx := c.Request.Context()
-	user, errWithCode := m.processor.Account().Create(
+	user, errWithCode := m.processor.User().Create(
 		ctx,
 		authed.Application,
 		form,
@@ -118,7 +118,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
 	}
 
 	// Get a token for the new user.
-	ti, errWithCode := m.processor.Account().TokenForNewUser(
+	ti, errWithCode := m.processor.User().TokenForNewUser(
 		ctx,
 		authed.Token,
 		authed.Application,
diff --git a/internal/api/client/accounts/accountdelete.go b/internal/api/client/accounts/accountdelete.go
index 947634f70..9a1ef7931 100644
--- a/internal/api/client/accounts/accountdelete.go
+++ b/internal/api/client/accounts/accountdelete.go
@@ -91,7 +91,7 @@ func (m *Module) AccountDeletePOSTHandler(c *gin.Context) {
 		return
 	}
 
-	if errWithCode := m.processor.Account().DeleteSelf(c.Request.Context(), authed.Account); errWithCode != nil {
+	if errWithCode := m.processor.User().DeleteSelf(c.Request.Context(), authed.Account); errWithCode != nil {
 		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
 		return
 	}
diff --git a/internal/api/client/admin/accountapprove.go b/internal/api/client/admin/accountapprove.go
index ff6474adb..7aaa48509 100644
--- a/internal/api/client/admin/accountapprove.go
+++ b/internal/api/client/admin/accountapprove.go
@@ -91,7 +91,7 @@ func (m *Module) AccountApprovePOSTHandler(c *gin.Context) {
 		return
 	}
 
-	account, errWithCode := m.processor.Admin().AccountApprove(
+	account, errWithCode := m.processor.Admin().SignupApprove(
 		c.Request.Context(),
 		authed.Account,
 		targetAcctID,
diff --git a/internal/api/client/admin/accountreject.go b/internal/api/client/admin/accountreject.go
index 1e909b508..a4653985d 100644
--- a/internal/api/client/admin/accountreject.go
+++ b/internal/api/client/admin/accountreject.go
@@ -119,7 +119,7 @@ func (m *Module) AccountRejectPOSTHandler(c *gin.Context) {
 		return
 	}
 
-	account, errWithCode := m.processor.Admin().AccountReject(
+	account, errWithCode := m.processor.Admin().SignupReject(
 		c.Request.Context(),
 		authed.Account,
 		targetAcctID,
diff --git a/internal/api/client/user/emailchange.go b/internal/api/client/user/emailchange.go
new file mode 100644
index 000000000..b2e25343f
--- /dev/null
+++ b/internal/api/client/user/emailchange.go
@@ -0,0 +1,104 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see .
+
+package user
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// EmailChangePOSTHandler swagger:operation POST /api/v1/user/email_change userEmailChange
+//
+// Request changing the email address of authenticated user.
+//
+//	---
+//	tags:
+//	- user
+//
+//	consumes:
+//	- application/json
+//	- application/xml
+//	- application/x-www-form-urlencoded
+//
+//	produces:
+//	- application/json
+//
+//	security:
+//	- OAuth2 Bearer:
+//		- write:user
+//
+//	responses:
+//		'202':
+//			description: "Accepted: email change is processing; check your inbox to confirm new address."
+//			schema:
+//				"$ref": "#/definitions/user"
+//		'400':
+//			description: bad request
+//		'401':
+//			description: unauthorized
+//		'403':
+//			description: forbidden
+//		'406':
+//			description: not acceptable
+//		'409':
+//			description: "Conflict: desired email address already in use"
+//		'500':
+//			description: internal error
+func (m *Module) EmailChangePOSTHandler(c *gin.Context) {
+	authed, err := oauth.Authed(c, true, true, true, true)
+	if err != nil {
+		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+		return
+	}
+
+	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+		return
+	}
+
+	form := &apimodel.EmailChangeRequest{}
+	if err := c.ShouldBind(form); err != nil {
+		apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+		return
+	}
+
+	if form.Password == "" {
+		err := errors.New("email change request missing field password")
+		apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+		return
+	}
+
+	user, errWithCode := m.processor.User().EmailChange(
+		c.Request.Context(),
+		authed.User,
+		form.Password,
+		form.NewEmail,
+	)
+	if errWithCode != nil {
+		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+		return
+	}
+
+	apiutil.JSON(c, http.StatusAccepted, user)
+}
diff --git a/internal/api/client/user/emailchange_test.go b/internal/api/client/user/emailchange_test.go
new file mode 100644
index 000000000..fce96c144
--- /dev/null
+++ b/internal/api/client/user/emailchange_test.go
@@ -0,0 +1,142 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see .
+
+package user_test
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/state"
+	"github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type EmailChangeTestSuite struct {
+	UserStandardTestSuite
+}
+
+func (suite *EmailChangeTestSuite) TestEmailChangePOST() {
+	// Get a new processor for this test, as
+	// we're expecting an email, and we don't
+	// want the other tests interfering if
+	// we're running them at the same time.
+	state := new(state.State)
+	state.DB = testrig.NewTestDB(&suite.state)
+	storage := testrig.NewInMemoryStorage()
+	sentEmails := make(map[string]string)
+	emailSender := testrig.NewEmailSender("../../../../web/template/", sentEmails)
+	processor := testrig.NewTestProcessor(state, suite.federator, emailSender, suite.mediaManager)
+	testrig.StartWorkers(state, processor.Workers())
+	userModule := user.New(processor)
+	testrig.StandardDBSetup(state.DB, suite.testAccounts)
+	testrig.StandardStorageSetup(storage, "../../../../testrig/media")
+
+	defer func() {
+		testrig.StandardDBTeardown(state.DB)
+		testrig.StandardStorageTeardown(storage)
+		testrig.StopWorkers(state)
+	}()
+
+	response, code := suite.POST(user.EmailChangePath, map[string][]string{
+		"password":  {"password"},
+		"new_email": {"someone@example.org"},
+	}, userModule.EmailChangePOSTHandler)
+	defer response.Body.Close()
+
+	// Check response
+	suite.EqualValues(http.StatusAccepted, code)
+	b, err := io.ReadAll(response.Body)
+	if err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	apiUser := new(apimodel.User)
+	if err := json.Unmarshal(b, apiUser); err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	// Unconfirmed email should be set now.
+	suite.Equal("someone@example.org", apiUser.UnconfirmedEmail)
+
+	// Ensure unconfirmed address gets an email.
+	if !testrig.WaitFor(func() bool {
+		_, ok := sentEmails["someone@example.org"]
+		return ok
+	}) {
+		suite.FailNow("no email received")
+	}
+}
+
+func (suite *EmailChangeTestSuite) TestEmailChangePOSTAddressInUse() {
+	response, code := suite.POST(user.EmailChangePath, map[string][]string{
+		"password":  {"password"},
+		"new_email": {"admin@example.org"},
+	}, suite.userModule.EmailChangePOSTHandler)
+	defer response.Body.Close()
+
+	// Check response
+	suite.EqualValues(http.StatusConflict, code)
+	b, err := io.ReadAll(response.Body)
+	if err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	suite.Equal(`{"error":"Conflict: new email address is already in use on this instance"}`, string(b))
+}
+
+func (suite *EmailChangeTestSuite) TestEmailChangePOSTSameEmail() {
+	response, code := suite.POST(user.EmailChangePath, map[string][]string{
+		"password":  {"password"},
+		"new_email": {"zork@example.org"},
+	}, suite.userModule.EmailChangePOSTHandler)
+	defer response.Body.Close()
+
+	// Check response
+	suite.EqualValues(http.StatusBadRequest, code)
+	b, err := io.ReadAll(response.Body)
+	if err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	suite.Equal(`{"error":"Bad Request: new email address cannot be the same as current email address"}`, string(b))
+}
+
+func (suite *EmailChangeTestSuite) TestEmailChangePOSTBadPassword() {
+	response, code := suite.POST(user.EmailChangePath, map[string][]string{
+		"password":  {"notmypassword"},
+		"new_email": {"someone@example.org"},
+	}, suite.userModule.EmailChangePOSTHandler)
+	defer response.Body.Close()
+
+	// Check response
+	suite.EqualValues(http.StatusUnauthorized, code)
+	b, err := io.ReadAll(response.Body)
+	if err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	suite.Equal(`{"error":"Unauthorized: password was incorrect"}`, string(b))
+}
+
+func TestEmailChangeTestSuite(t *testing.T) {
+	suite.Run(t, &EmailChangeTestSuite{})
+}
diff --git a/internal/api/client/user/passwordchange_test.go b/internal/api/client/user/passwordchange_test.go
index b820696b5..8a741f96c 100644
--- a/internal/api/client/user/passwordchange_test.go
+++ b/internal/api/client/user/passwordchange_test.go
@@ -19,18 +19,13 @@ package user_test
 
 import (
 	"context"
-	"fmt"
-	"io/ioutil"
+	"io"
 	"net/http"
-	"net/http/httptest"
-	"net/url"
 	"testing"
 
 	"github.com/stretchr/testify/suite"
 	"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/testrig"
 	"golang.org/x/crypto/bcrypt"
 )
 
@@ -39,29 +34,20 @@ type PasswordChangeTestSuite struct {
 }
 
 func (suite *PasswordChangeTestSuite) TestPasswordChangePOST() {
-	t := suite.testTokens["local_account_1"]
-	oauthToken := oauth.DBTokenToToken(t)
-
-	recorder := httptest.NewRecorder()
-	ctx, _ := testrig.CreateGinTestContext(recorder, nil)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
-	ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
-	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
-	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil)
-	ctx.Request.Header.Set("accept", "application/json")
-	ctx.Request.Form = url.Values{
+	response, code := suite.POST(user.PasswordChangePath, map[string][]string{
 		"old_password": {"password"},
 		"new_password": {"peepeepoopoopassword"},
-	}
-	suite.userModule.PasswordChangePOSTHandler(ctx)
+	}, suite.userModule.PasswordChangePOSTHandler)
+	defer response.Body.Close()
 
-	// check response
-	suite.EqualValues(http.StatusOK, recorder.Code)
+	// Check response
+	suite.EqualValues(http.StatusOK, code)
 
 	dbUser := >smodel.User{}
 	err := suite.db.GetByID(context.Background(), suite.testUsers["local_account_1"].ID, dbUser)
-	suite.NoError(err)
+	if err != nil {
+		suite.FailNow(err.Error())
+	}
 
 	// new password should pass
 	err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("peepeepoopoopassword"))
@@ -73,85 +59,49 @@ func (suite *PasswordChangeTestSuite) TestPasswordChangePOST() {
 }
 
 func (suite *PasswordChangeTestSuite) TestPasswordMissingOldPassword() {
-	t := suite.testTokens["local_account_1"]
-	oauthToken := oauth.DBTokenToToken(t)
-
-	recorder := httptest.NewRecorder()
-	ctx, _ := testrig.CreateGinTestContext(recorder, nil)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
-	ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
-	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
-	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil)
-	ctx.Request.Header.Set("accept", "application/json")
-	ctx.Request.Form = url.Values{
+	response, code := suite.POST(user.PasswordChangePath, map[string][]string{
 		"new_password": {"peepeepoopoopassword"},
+	}, suite.userModule.PasswordChangePOSTHandler)
+	defer response.Body.Close()
+
+	// Check response
+	suite.EqualValues(http.StatusBadRequest, code)
+	b, err := io.ReadAll(response.Body)
+	if err != nil {
+		suite.FailNow(err.Error())
 	}
-	suite.userModule.PasswordChangePOSTHandler(ctx)
-
-	// check response
-	suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	suite.NoError(err)
 	suite.Equal(`{"error":"Bad Request: password change request missing field old_password"}`, string(b))
 }
 
 func (suite *PasswordChangeTestSuite) TestPasswordIncorrectOldPassword() {
-	t := suite.testTokens["local_account_1"]
-	oauthToken := oauth.DBTokenToToken(t)
-
-	recorder := httptest.NewRecorder()
-	ctx, _ := testrig.CreateGinTestContext(recorder, nil)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
-	ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
-	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
-	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil)
-	ctx.Request.Header.Set("accept", "application/json")
-	ctx.Request.Form = url.Values{
+	response, code := suite.POST(user.PasswordChangePath, map[string][]string{
 		"old_password": {"notright"},
 		"new_password": {"peepeepoopoopassword"},
+	}, suite.userModule.PasswordChangePOSTHandler)
+	defer response.Body.Close()
+
+	// Check response
+	suite.EqualValues(http.StatusUnauthorized, code)
+	b, err := io.ReadAll(response.Body)
+	if err != nil {
+		suite.FailNow(err.Error())
 	}
-	suite.userModule.PasswordChangePOSTHandler(ctx)
-
-	// check response
-	suite.EqualValues(http.StatusUnauthorized, recorder.Code)
-
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	suite.NoError(err)
 	suite.Equal(`{"error":"Unauthorized: old password was incorrect"}`, string(b))
 }
 
 func (suite *PasswordChangeTestSuite) TestPasswordWeakNewPassword() {
-	t := suite.testTokens["local_account_1"]
-	oauthToken := oauth.DBTokenToToken(t)
-
-	recorder := httptest.NewRecorder()
-	ctx, _ := testrig.CreateGinTestContext(recorder, nil)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
-	ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
-	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
-	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil)
-	ctx.Request.Header.Set("accept", "application/json")
-	ctx.Request.Form = url.Values{
+	response, code := suite.POST(user.PasswordChangePath, map[string][]string{
 		"old_password": {"password"},
 		"new_password": {"peepeepoopoo"},
+	}, suite.userModule.PasswordChangePOSTHandler)
+	defer response.Body.Close()
+
+	// Check response
+	suite.EqualValues(http.StatusBadRequest, code)
+	b, err := io.ReadAll(response.Body)
+	if err != nil {
+		suite.FailNow(err.Error())
 	}
-	suite.userModule.PasswordChangePOSTHandler(ctx)
-
-	// check response
-	suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	suite.NoError(err)
 	suite.Equal(`{"error":"Bad Request: password is only 94% strength, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
 }
 
diff --git a/internal/api/client/user/user.go b/internal/api/client/user/user.go
index 487b9684c..6ad176a2e 100644
--- a/internal/api/client/user/user.go
+++ b/internal/api/client/user/user.go
@@ -29,6 +29,8 @@ const (
 	BasePath = "/v1/user"
 	// PasswordChangePath is the path for POSTing a password change request.
 	PasswordChangePath = BasePath + "/password_change"
+	// EmailChangePath is the path for POSTing an email address change request.
+	EmailChangePath = BasePath + "/email_change"
 )
 
 type Module struct {
@@ -42,5 +44,7 @@ func New(processor *processing.Processor) *Module {
 }
 
 func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
+	attachHandler(http.MethodGet, BasePath, m.UserGETHandler)
 	attachHandler(http.MethodPost, PasswordChangePath, m.PasswordChangePOSTHandler)
+	attachHandler(http.MethodPost, EmailChangePath, m.EmailChangePOSTHandler)
 }
diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go
index efff89b13..808daf1a3 100644
--- a/internal/api/client/user/user_test.go
+++ b/internal/api/client/user/user_test.go
@@ -18,14 +18,19 @@
 package user_test
 
 import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+
+	"github.com/gin-gonic/gin"
 	"github.com/stretchr/testify/suite"
 	"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/email"
 	"github.com/superseriousbusiness/gotosocial/internal/federation"
 	"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/gotosocial/internal/processing"
 	"github.com/superseriousbusiness/gotosocial/internal/state"
 	"github.com/superseriousbusiness/gotosocial/internal/storage"
@@ -39,7 +44,6 @@ type UserStandardTestSuite struct {
 	tc           *typeutils.Converter
 	mediaManager *media.Manager
 	federator    *federation.Federator
-	emailSender  email.Sender
 	processor    *processing.Processor
 	storage      *storage.Driver
 	state        state.State
@@ -50,8 +54,6 @@ type UserStandardTestSuite struct {
 	testUsers        map[string]*gtsmodel.User
 	testAccounts     map[string]*gtsmodel.Account
 
-	sentEmails map[string]string
-
 	userModule *user.Module
 }
 
@@ -83,9 +85,7 @@ func (suite *UserStandardTestSuite) SetupTest() {
 
 	suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
 	suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
-	suite.sentEmails = make(map[string]string)
-	suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
-	suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+	suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, testrig.NewEmailSender("../../../../web/template/", nil), suite.mediaManager)
 	suite.userModule = user.New(suite.processor)
 	testrig.StandardDBSetup(suite.db, suite.testAccounts)
 	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
@@ -96,3 +96,32 @@ func (suite *UserStandardTestSuite) TearDownTest() {
 	testrig.StandardStorageTeardown(suite.storage)
 	testrig.StopWorkers(&suite.state)
 }
+
+func (suite *UserStandardTestSuite) POST(path string, formValues map[string][]string, handler gin.HandlerFunc) (*http.Response, int) {
+	var (
+		oauthToken = oauth.DBTokenToToken(suite.testTokens["local_account_1"])
+		app        = suite.testApplications["application_1"]
+		user       = suite.testUsers["local_account_1"]
+		account    = suite.testAccounts["local_account_1"]
+		target     = "http://localhost:8080" + path
+	)
+
+	// Prepare context.
+	recorder := httptest.NewRecorder()
+	ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+	ctx.Set(oauth.SessionAuthorizedApplication, app)
+	ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+	ctx.Set(oauth.SessionAuthorizedUser, user)
+	ctx.Set(oauth.SessionAuthorizedAccount, account)
+
+	// Prepare request.
+	ctx.Request = httptest.NewRequest(http.MethodPost, target, nil)
+	ctx.Request.Header.Set("accept", "application/json")
+	ctx.Request.Form = url.Values(formValues)
+
+	// Call the handler.
+	handler(ctx)
+
+	// Return response.
+	return recorder.Result(), recorder.Code
+}
diff --git a/internal/api/client/user/userget.go b/internal/api/client/user/userget.go
new file mode 100644
index 000000000..147c1dbd5
--- /dev/null
+++ b/internal/api/client/user/userget.go
@@ -0,0 +1,78 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see .
+
+package user
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// UserGETHandler swagger:operation GET /api/v1/user getUser
+//
+// Get your own user model.
+//
+//	---
+//	tags:
+//	- user
+//
+//	produces:
+//	- application/json
+//
+//	security:
+//	- OAuth2 Bearer:
+//		- read:user
+//
+//	responses:
+//		'200':
+//			description: The requested user.
+//			schema:
+//				"$ref": "#/definitions/user"
+//		'400':
+//			description: bad request
+//		'401':
+//			description: unauthorized
+//		'403':
+//			description: forbidden
+//		'406':
+//			description: not acceptable
+//		'500':
+//			description: internal error
+func (m *Module) UserGETHandler(c *gin.Context) {
+	authed, err := oauth.Authed(c, true, true, true, true)
+	if err != nil {
+		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+		return
+	}
+
+	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+		return
+	}
+
+	user, errWithCode := m.processor.User().Get(c.Request.Context(), authed.User)
+	if errWithCode != nil {
+		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+		return
+	}
+
+	apiutil.JSON(c, http.StatusOK, user)
+}
diff --git a/internal/api/model/user.go b/internal/api/model/user.go
index 1a70a90d7..9226406d6 100644
--- a/internal/api/model/user.go
+++ b/internal/api/model/user.go
@@ -17,6 +17,51 @@
 
 package model
 
+// User models fields relevant to one user.
+//
+// swagger:model user
+type User struct {
+	// Database ID of this user.
+	// example: 01FBVD42CQ3ZEEVMW180SBX03B
+	ID string `json:"id"`
+	// Time this user was created. (ISO 8601 Datetime)
+	// example: 2021-07-30T09:20:25+00:00
+	CreatedAt string `json:"created_at"`
+	// Confirmed email address of this user, if set.
+	// example: someone@example.org
+	Email string `json:"email,omitempty"`
+	// Unconfirmed email address of this user, if set.
+	// example: someone.else@somewhere.else.example.org
+	UnconfirmedEmail string `json:"unconfirmed_email,omitempty"`
+	// Reason for sign-up, if provided.
+	// example: Please! Pretty please!
+	Reason string `json:"reason,omitempty"`
+	// Time at which this user was last emailed, if at all. (ISO 8601 Datetime)
+	// example: 2021-07-30T09:20:25+00:00
+	LastEmailedAt string `json:"last_emailed_at,omitempty"`
+	// Time at which the email given in the `email` field was confirmed, if at all. (ISO 8601 Datetime)
+	// example: 2021-07-30T09:20:25+00:00
+	ConfirmedAt string `json:"confirmed_at,omitempty"`
+	// Time when the last "please confirm your email address" email was sent, if at all. (ISO 8601 Datetime)
+	// example: 2021-07-30T09:20:25+00:00
+	ConfirmationSentAt string `json:"confirmation_sent_at,omitempty"`
+	// User is a moderator.
+	// example: false
+	Moderator bool `json:"moderator"`
+	// User is an admin.
+	// example: false
+	Admin bool `json:"admin"`
+	// User's account is disabled.
+	// example: false
+	Disabled bool `json:"disabled"`
+	// User was approved by an admin.
+	// example: true
+	Approved bool `json:"approved"`
+	// Time when the last "please reset your password" email was sent, if at all. (ISO 8601 Datetime)
+	// example: 2021-07-30T09:20:25+00:00
+	ResetPasswordSentAt string `json:"reset_password_sent_at,omitempty"`
+}
+
 // PasswordChangeRequest models user password change parameters.
 //
 // swagger:parameters userPasswordChange
@@ -34,3 +79,19 @@ type PasswordChangeRequest struct {
 	// required: true
 	NewPassword string `form:"new_password" json:"new_password" xml:"new_password" validation:"required"`
 }
+
+// EmailChangeRequest models user email change parameters.
+//
+// swagger:parameters userEmailChange
+type EmailChangeRequest struct {
+	// User's current password, for verification.
+	//
+	// in: formData
+	// required: true
+	Password string `form:"password" json:"password" xml:"password" validation:"required"`
+	// Desired new email address.
+	//
+	// in: formData
+	// required: true
+	NewEmail string `form:"new_email" json:"new_email" xml:"new_email" validation:"required"`
+}
-- 
cgit v1.2.3