Pārlūkot izejas kodu

refactor(db): migrate off `user_email.go` to `users.go` (#7452)

Joe Chen 1 gadu atpakaļ
vecāks
revīzija
0721ef2399

+ 9 - 5
.github/workflows/go.yml

@@ -30,12 +30,11 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout code
-        uses: actions/checkout@v2
-      - name: Run golangci-lint
-        uses: golangci/golangci-lint-action@v2
+        uses: actions/checkout@v3
+      - name: Install Go
+        uses: actions/setup-go@v4
         with:
-          version: latest
-          args: --timeout=30m
+          go-version: 1.20.x
       - name: Install Task
         uses: arduino/setup-task@v1
         with:
@@ -52,6 +51,11 @@ jobs:
             echo "Run 'go mod tidy' or 'task generate' commit them"
             exit 1
           fi
+      - name: Run golangci-lint
+        uses: golangci/golangci-lint-action@v3
+        with:
+          version: latest
+          args: --timeout=30m
 
   test:
     name: Test

+ 1 - 0
conf/locale/locale_en-US.ini

@@ -317,6 +317,7 @@ delete_email = Delete
 email_deletion = Email Deletion
 email_deletion_desc = Deleting this email address will remove related information from your account. Do you want to continue?
 email_deletion_success = Email has been deleted successfully!
+email_deletion_primary = Cannot delete primary email address.
 add_new_email = Add new email address
 add_email = Add Email
 add_email_confirmation_sent = A new confirmation email has been sent to '%s', please check your inbox within the next %d hours to complete the confirmation process.

+ 16 - 0
docs/dev/database_schema.md

@@ -55,6 +55,22 @@ Indexes:
 	"idx_action_user_id" (user_id)
 ```
 
+# Table "email_address"
+
+```
+     FIELD    |    COLUMN    |           POSTGRESQL           |             MYSQL              |            SQLITE3              
+--------------+--------------+--------------------------------+--------------------------------+---------------------------------
+  ID          | id           | BIGSERIAL                      | BIGINT AUTO_INCREMENT          | INTEGER                         
+  UserID      | uid          | BIGINT NOT NULL                | BIGINT NOT NULL                | INTEGER NOT NULL                
+  Email       | email        | VARCHAR(254) NOT NULL          | VARCHAR(254) NOT NULL          | TEXT NOT NULL                   
+  IsActivated | is_activated | BOOLEAN NOT NULL DEFAULT FALSE | BOOLEAN NOT NULL DEFAULT FALSE | NUMERIC NOT NULL DEFAULT FALSE  
+
+Primary keys: id
+Indexes: 
+	"email_address_user_email_unique" UNIQUE (uid, email)
+	"idx_email_address_user_id" (uid)
+```
+
 # Table "follow"
 
 ```

+ 16 - 2
internal/db/backup_test.go

@@ -31,8 +31,9 @@ func TestDumpAndImport(t *testing.T) {
 	}
 	t.Parallel()
 
-	if len(Tables) != 6 {
-		t.Fatalf("New table has added (want 6 got %d), please add new tests for the table and update this check", len(Tables))
+	const wantTables = 7
+	if len(Tables) != wantTables {
+		t.Fatalf("New table has added (want %d got %d), please add new tests for the table and update this check", wantTables, len(Tables))
 	}
 
 	db := dbtest.NewDB(t, "dumpAndImport", Tables...)
@@ -131,6 +132,19 @@ func setupDBToDump(t *testing.T, db *gorm.DB) {
 			CreatedUnix:  1588568886,
 		},
 
+		&EmailAddress{
+			ID:          1,
+			UserID:      1,
+			Email:       "[email protected]",
+			IsActivated: false,
+		},
+		&EmailAddress{
+			ID:          2,
+			UserID:      2,
+			Email:       "[email protected]",
+			IsActivated: true,
+		},
+
 		&Follow{
 			ID:       1,
 			UserID:   1,

+ 1 - 1
internal/db/db.go

@@ -42,6 +42,7 @@ func newLogWriter() (logger.Writer, error) {
 // NOTE: Lines are sorted in alphabetical order, each letter in its own line.
 var Tables = []any{
 	new(Access), new(AccessToken), new(Action),
+	new(EmailAddress),
 	new(Follow),
 	new(LFSObject), new(LoginSource),
 }
@@ -121,7 +122,6 @@ func Init(w logger.Writer) (*gorm.DB, error) {
 	// Initialize stores, sorted in alphabetical order.
 	AccessTokens = &accessTokens{DB: db}
 	Actions = NewActionsStore(db)
-	EmailAddresses = NewEmailAddressesStore(db)
 	LoginSources = &loginSources{DB: db, files: sourceFiles}
 	LFS = &lfs{DB: db}
 	Orgs = NewOrgsStore(db)

+ 0 - 80
internal/db/email_addresses.go

@@ -1,80 +0,0 @@
-// Copyright 2022 The Gogs Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package db
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/pkg/errors"
-	"gorm.io/gorm"
-
-	"gogs.io/gogs/internal/errutil"
-)
-
-// EmailAddressesStore is the persistent interface for email addresses.
-type EmailAddressesStore interface {
-	// GetByEmail returns the email address with given email. If `needsActivated` is
-	// true, only activated email will be returned, otherwise, it may return
-	// inactivated email addresses. It returns ErrEmailNotExist when no qualified
-	// email is not found.
-	GetByEmail(ctx context.Context, email string, needsActivated bool) (*EmailAddress, error)
-}
-
-var EmailAddresses EmailAddressesStore
-
-var _ EmailAddressesStore = (*emailAddresses)(nil)
-
-type emailAddresses struct {
-	*gorm.DB
-}
-
-// NewEmailAddressesStore returns a persistent interface for email addresses
-// with given database connection.
-func NewEmailAddressesStore(db *gorm.DB) EmailAddressesStore {
-	return &emailAddresses{DB: db}
-}
-
-var _ errutil.NotFound = (*ErrEmailNotExist)(nil)
-
-type ErrEmailNotExist struct {
-	args errutil.Args
-}
-
-// IsErrEmailAddressNotExist returns true if the underlying error has the type
-// ErrEmailNotExist.
-func IsErrEmailAddressNotExist(err error) bool {
-	_, ok := errors.Cause(err).(ErrEmailNotExist)
-	return ok
-}
-
-func (err ErrEmailNotExist) Error() string {
-	return fmt.Sprintf("email address does not exist: %v", err.args)
-}
-
-func (ErrEmailNotExist) NotFound() bool {
-	return true
-}
-
-func (db *emailAddresses) GetByEmail(ctx context.Context, email string, needsActivated bool) (*EmailAddress, error) {
-	tx := db.WithContext(ctx).Where("email = ?", email)
-	if needsActivated {
-		tx = tx.Where("is_activated = ?", true)
-	}
-
-	emailAddress := new(EmailAddress)
-	err := tx.First(emailAddress).Error
-	if err != nil {
-		if err == gorm.ErrRecordNotFound {
-			return nil, ErrEmailNotExist{
-				args: errutil.Args{
-					"email": email,
-				},
-			}
-		}
-		return nil, err
-	}
-	return emailAddress, nil
-}

+ 0 - 77
internal/db/email_addresses_test.go

@@ -1,77 +0,0 @@
-// Copyright 2022 The Gogs Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package db
-
-import (
-	"context"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-
-	"gogs.io/gogs/internal/dbtest"
-	"gogs.io/gogs/internal/errutil"
-)
-
-func TestEmailAddresses(t *testing.T) {
-	if testing.Short() {
-		t.Skip()
-	}
-	t.Parallel()
-
-	tables := []any{new(EmailAddress)}
-	db := &emailAddresses{
-		DB: dbtest.NewDB(t, "emailAddresses", tables...),
-	}
-
-	for _, tc := range []struct {
-		name string
-		test func(t *testing.T, db *emailAddresses)
-	}{
-		{"GetByEmail", emailAddressesGetByEmail},
-	} {
-		t.Run(tc.name, func(t *testing.T) {
-			t.Cleanup(func() {
-				err := clearTables(t, db.DB, tables...)
-				require.NoError(t, err)
-			})
-			tc.test(t, db)
-		})
-		if t.Failed() {
-			break
-		}
-	}
-}
-
-func emailAddressesGetByEmail(t *testing.T, db *emailAddresses) {
-	ctx := context.Background()
-
-	const testEmail = "[email protected]"
-	_, err := db.GetByEmail(ctx, testEmail, false)
-	wantErr := ErrEmailNotExist{
-		args: errutil.Args{
-			"email": testEmail,
-		},
-	}
-	assert.Equal(t, wantErr, err)
-
-	// TODO: Use EmailAddresses.Create to replace SQL hack when the method is available.
-	err = db.Exec(`INSERT INTO email_address (uid, email, is_activated) VALUES (1, ?, FALSE)`, testEmail).Error
-	require.NoError(t, err)
-	got, err := db.GetByEmail(ctx, testEmail, false)
-	require.NoError(t, err)
-	assert.Equal(t, testEmail, got.Email)
-
-	// Should not return if we only want activated emails
-	_, err = db.GetByEmail(ctx, testEmail, true)
-	assert.Equal(t, wantErr, err)
-
-	// TODO: Use EmailAddresses.MarkActivated to replace SQL hack when the method is available.
-	err = db.Exec(`UPDATE email_address SET is_activated = TRUE WHERE email = ?`, testEmail).Error
-	require.NoError(t, err)
-	got, err = db.GetByEmail(ctx, testEmail, true)
-	require.NoError(t, err)
-	assert.Equal(t, testEmail, got.Email)
-}

+ 0 - 33
internal/db/errors/user_mail.go

@@ -1,33 +0,0 @@
-// Copyright 2017 The Gogs Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package errors
-
-import "fmt"
-
-type EmailNotFound struct {
-	Email string
-}
-
-func IsEmailNotFound(err error) bool {
-	_, ok := err.(EmailNotFound)
-	return ok
-}
-
-func (err EmailNotFound) Error() string {
-	return fmt.Sprintf("email is not found [email: %s]", err.Email)
-}
-
-type EmailNotVerified struct {
-	Email string
-}
-
-func IsEmailNotVerified(err error) bool {
-	_, ok := err.(EmailNotVerified)
-	return ok
-}
-
-func (err EmailNotVerified) Error() string {
-	return fmt.Sprintf("email has not been verified [email: %s]", err.Email)
-}

+ 1 - 1
internal/db/models.go

@@ -58,7 +58,7 @@ func init() {
 		new(Mirror), new(Release), new(Webhook), new(HookTask),
 		new(ProtectBranch), new(ProtectBranchWhitelist),
 		new(Team), new(OrgUser), new(TeamUser), new(TeamRepo),
-		new(Notice), new(EmailAddress))
+		new(Notice))
 
 	gonicNames := []string{"SSL"}
 	for _, name := range gonicNames {

+ 2 - 0
internal/db/testdata/backup/EmailAddress.golden.json

@@ -0,0 +1,2 @@
+{"ID":1,"UserID":1,"Email":"[email protected]","IsActivated":false}
+{"ID":2,"UserID":2,"Email":"[email protected]","IsActivated":true}

+ 0 - 199
internal/db/user_mail.go

@@ -1,199 +0,0 @@
-// Copyright 2016 The Gogs Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package db
-
-import (
-	"context"
-	"fmt"
-	"strings"
-
-	"gogs.io/gogs/internal/db/errors"
-	"gogs.io/gogs/internal/errutil"
-)
-
-// EmailAddresses is the list of all email addresses of a user. Can contain the
-// primary email address, but is not obligatory.
-type EmailAddress struct {
-	ID          int64  `gorm:"primaryKey"`
-	UserID      int64  `xorm:"uid INDEX NOT NULL" gorm:"column:uid;index;not null"`
-	Email       string `xorm:"UNIQUE NOT NULL" gorm:"unique;not null"`
-	IsActivated bool   `gorm:"not null;default:FALSE"`
-	IsPrimary   bool   `xorm:"-" gorm:"-" json:"-"`
-}
-
-// GetEmailAddresses returns all email addresses belongs to given user.
-func GetEmailAddresses(uid int64) ([]*EmailAddress, error) {
-	emails := make([]*EmailAddress, 0, 5)
-	if err := x.Where("uid=?", uid).Find(&emails); err != nil {
-		return nil, err
-	}
-
-	u, err := Users.GetByID(context.TODO(), uid)
-	if err != nil {
-		return nil, err
-	}
-
-	isPrimaryFound := false
-	for _, email := range emails {
-		if email.Email == u.Email {
-			isPrimaryFound = true
-			email.IsPrimary = true
-		} else {
-			email.IsPrimary = false
-		}
-	}
-
-	// We always want the primary email address displayed, even if it's not in
-	// the emailaddress table (yet).
-	if !isPrimaryFound {
-		emails = append(emails, &EmailAddress{
-			Email:       u.Email,
-			IsActivated: true,
-			IsPrimary:   true,
-		})
-	}
-	return emails, nil
-}
-
-func isEmailUsed(e Engine, email string) (bool, error) {
-	if email == "" {
-		return true, nil
-	}
-
-	has, err := e.Get(&EmailAddress{Email: email})
-	if err != nil {
-		return false, err
-	} else if has {
-		return true, nil
-	}
-
-	// We need to check primary email of users as well.
-	return e.Where("type=?", UserTypeIndividual).And("email=?", email).Get(new(User))
-}
-
-// IsEmailUsed returns true if the email has been used.
-func IsEmailUsed(email string) (bool, error) {
-	return isEmailUsed(x, email)
-}
-
-func addEmailAddress(e Engine, email *EmailAddress) error {
-	email.Email = strings.ToLower(strings.TrimSpace(email.Email))
-	used, err := isEmailUsed(e, email.Email)
-	if err != nil {
-		return err
-	} else if used {
-		return ErrEmailAlreadyUsed{args: errutil.Args{"email": email.Email}}
-	}
-
-	_, err = e.Insert(email)
-	return err
-}
-
-func AddEmailAddress(email *EmailAddress) error {
-	return addEmailAddress(x, email)
-}
-
-func AddEmailAddresses(emails []*EmailAddress) error {
-	if len(emails) == 0 {
-		return nil
-	}
-
-	// Check if any of them has been used
-	for i := range emails {
-		emails[i].Email = strings.ToLower(strings.TrimSpace(emails[i].Email))
-		used, err := IsEmailUsed(emails[i].Email)
-		if err != nil {
-			return err
-		} else if used {
-			return ErrEmailAlreadyUsed{args: errutil.Args{"email": emails[i].Email}}
-		}
-	}
-
-	if _, err := x.Insert(emails); err != nil {
-		return fmt.Errorf("Insert: %v", err)
-	}
-
-	return nil
-}
-
-func (email *EmailAddress) Activate() error {
-	email.IsActivated = true
-	if _, err := x.ID(email.ID).AllCols().Update(email); err != nil {
-		return err
-	}
-	return Users.Update(context.TODO(), email.UserID, UpdateUserOptions{GenerateNewRands: true})
-}
-
-func DeleteEmailAddress(email *EmailAddress) (err error) {
-	if email.ID > 0 {
-		_, err = x.Id(email.ID).Delete(new(EmailAddress))
-	} else {
-		_, err = x.Where("email=?", email.Email).Delete(new(EmailAddress))
-	}
-	return err
-}
-
-func DeleteEmailAddresses(emails []*EmailAddress) (err error) {
-	for i := range emails {
-		if err = DeleteEmailAddress(emails[i]); err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
-func MakeEmailPrimary(userID int64, email *EmailAddress) error {
-	has, err := x.Get(email)
-	if err != nil {
-		return err
-	} else if !has {
-		return errors.EmailNotFound{Email: email.Email}
-	}
-
-	if email.UserID != userID {
-		return errors.New("not the owner of the email")
-	}
-
-	if !email.IsActivated {
-		return errors.EmailNotVerified{Email: email.Email}
-	}
-
-	user := &User{ID: email.UserID}
-	has, err = x.Get(user)
-	if err != nil {
-		return err
-	} else if !has {
-		return ErrUserNotExist{args: map[string]any{"userID": email.UserID}}
-	}
-
-	// Make sure the former primary email doesn't disappear.
-	formerPrimaryEmail := &EmailAddress{Email: user.Email}
-	has, err = x.Get(formerPrimaryEmail)
-	if err != nil {
-		return err
-	}
-
-	sess := x.NewSession()
-	defer sess.Close()
-	if err = sess.Begin(); err != nil {
-		return err
-	}
-
-	if !has {
-		formerPrimaryEmail.UserID = user.ID
-		formerPrimaryEmail.IsActivated = user.IsActive
-		if _, err = sess.Insert(formerPrimaryEmail); err != nil {
-			return err
-		}
-	}
-
-	user.Email = email.Email
-	if _, err = sess.ID(user.ID).AllCols().Update(user); err != nil {
-		return err
-	}
-
-	return sess.Commit()
-}

+ 209 - 2
internal/db/users.go

@@ -49,7 +49,7 @@ type UsersStore interface {
 	// Create creates a new user and persists to database. It returns
 	// ErrNameNotAllowed if the given name or pattern of the name is not allowed as
 	// a username, or ErrUserAlreadyExist when a user with same name already exists,
-	// or ErrEmailAlreadyUsed if the email has been used by another user.
+	// or ErrEmailAlreadyUsed if the email has been verified by another user.
 	Create(ctx context.Context, username, email string, opts CreateUserOptions) (*User, error)
 
 	// GetByEmail returns the user (not organization) with given email. It ignores
@@ -101,6 +101,27 @@ type UsersStore interface {
 	// DeleteInactivated deletes all inactivated users.
 	DeleteInactivated() error
 
+	// AddEmail adds a new email address to given user. It returns
+	// ErrEmailAlreadyUsed if the email has been verified by another user.
+	AddEmail(ctx context.Context, userID int64, email string, isActivated bool) error
+	// GetEmail returns the email address of the given user. If `needsActivated` is
+	// true, only activated email will be returned, otherwise, it may return
+	// inactivated email addresses. It returns ErrEmailNotExist when no qualified
+	// email is not found.
+	GetEmail(ctx context.Context, userID int64, email string, needsActivated bool) (*EmailAddress, error)
+	// ListEmails returns all email addresses of the given user. It always includes
+	// a primary email address.
+	ListEmails(ctx context.Context, userID int64) ([]*EmailAddress, error)
+	// MarkEmailActivated marks the email address of the given user as activated,
+	// and new rands are generated for the user.
+	MarkEmailActivated(ctx context.Context, userID int64, email string) error
+	// MarkEmailPrimary marks the email address of the given user as primary. It
+	// returns ErrEmailNotExist when the email is not found for the user, and
+	// ErrEmailNotActivated when the email is not activated.
+	MarkEmailPrimary(ctx context.Context, userID int64, email string) error
+	// DeleteEmail deletes the email address of the given user.
+	DeleteEmail(ctx context.Context, userID int64, email string) error
+
 	// Follow marks the user to follow the other user.
 	Follow(ctx context.Context, userID, followID int64) error
 	// Unfollow removes the mark the user to follow the other user.
@@ -386,7 +407,7 @@ func (db *users) Create(ctx context.Context, username, email string, opts Create
 		}
 	}
 
-	email = strings.ToLower(email)
+	email = strings.ToLower(strings.TrimSpace(email))
 	_, err = db.GetByEmail(ctx, email)
 	if err == nil {
 		return nil, ErrEmailAlreadyUsed{
@@ -1061,6 +1082,183 @@ func (db *users) UseCustomAvatar(ctx context.Context, userID int64, avatar []byt
 		Error
 }
 
+func (db *users) AddEmail(ctx context.Context, userID int64, email string, isActivated bool) error {
+	email = strings.ToLower(strings.TrimSpace(email))
+	_, err := db.GetByEmail(ctx, email)
+	if err == nil {
+		return ErrEmailAlreadyUsed{
+			args: errutil.Args{
+				"email": email,
+			},
+		}
+	} else if !IsErrUserNotExist(err) {
+		return errors.Wrap(err, "check user by email")
+	}
+
+	return db.WithContext(ctx).Create(
+		&EmailAddress{
+			UserID:      userID,
+			Email:       email,
+			IsActivated: isActivated,
+		},
+	).Error
+}
+
+var _ errutil.NotFound = (*ErrEmailNotExist)(nil)
+
+type ErrEmailNotExist struct {
+	args errutil.Args
+}
+
+// IsErrEmailAddressNotExist returns true if the underlying error has the type
+// ErrEmailNotExist.
+func IsErrEmailAddressNotExist(err error) bool {
+	_, ok := errors.Cause(err).(ErrEmailNotExist)
+	return ok
+}
+
+func (err ErrEmailNotExist) Error() string {
+	return fmt.Sprintf("email address does not exist: %v", err.args)
+}
+
+func (ErrEmailNotExist) NotFound() bool {
+	return true
+}
+
+func (db *users) GetEmail(ctx context.Context, userID int64, email string, needsActivated bool) (*EmailAddress, error) {
+	tx := db.WithContext(ctx).Where("uid = ? AND email = ?", userID, email)
+	if needsActivated {
+		tx = tx.Where("is_activated = ?", true)
+	}
+
+	emailAddress := new(EmailAddress)
+	err := tx.First(emailAddress).Error
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			return nil, ErrEmailNotExist{
+				args: errutil.Args{
+					"email": email,
+				},
+			}
+		}
+		return nil, err
+	}
+	return emailAddress, nil
+}
+
+func (db *users) ListEmails(ctx context.Context, userID int64) ([]*EmailAddress, error) {
+	user, err := db.GetByID(ctx, userID)
+	if err != nil {
+		return nil, errors.Wrap(err, "get user")
+	}
+
+	var emails []*EmailAddress
+	err = db.WithContext(ctx).Where("uid = ?", userID).Order("id ASC").Find(&emails).Error
+	if err != nil {
+		return nil, errors.Wrap(err, "list emails")
+	}
+
+	isPrimaryFound := false
+	for _, email := range emails {
+		if email.Email == user.Email {
+			isPrimaryFound = true
+			email.IsPrimary = true
+			break
+		}
+	}
+
+	// We always want the primary email address displayed, even if it's not in the
+	// email_address table yet.
+	if !isPrimaryFound {
+		emails = append(emails, &EmailAddress{
+			Email:       user.Email,
+			IsActivated: user.IsActive,
+			IsPrimary:   true,
+		})
+	}
+	return emails, nil
+}
+
+func (db *users) MarkEmailActivated(ctx context.Context, userID int64, email string) error {
+	return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		err := db.WithContext(ctx).
+			Model(&EmailAddress{}).
+			Where("uid = ? AND email = ?", userID, email).
+			Update("is_activated", true).
+			Error
+		if err != nil {
+			return errors.Wrap(err, "mark email activated")
+		}
+
+		return NewUsersStore(tx).Update(ctx, userID, UpdateUserOptions{GenerateNewRands: true})
+	})
+}
+
+type ErrEmailNotVerified struct {
+	args errutil.Args
+}
+
+// IsErrEmailNotVerified returns true if the underlying error has the type
+// ErrEmailNotVerified.
+func IsErrEmailNotVerified(err error) bool {
+	_, ok := errors.Cause(err).(ErrEmailNotVerified)
+	return ok
+}
+
+func (err ErrEmailNotVerified) Error() string {
+	return fmt.Sprintf("email has not been verified: %v", err.args)
+}
+
+func (db *users) MarkEmailPrimary(ctx context.Context, userID int64, email string) error {
+	var emailAddress EmailAddress
+	err := db.WithContext(ctx).Where("uid = ? AND email = ?", userID, email).First(&emailAddress).Error
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			return ErrEmailNotExist{args: errutil.Args{"email": email}}
+		}
+		return errors.Wrap(err, "get email address")
+	}
+
+	if !emailAddress.IsActivated {
+		return ErrEmailNotVerified{args: errutil.Args{"email": email}}
+	}
+
+	user, err := db.GetByID(ctx, userID)
+	if err != nil {
+		return errors.Wrap(err, "get user")
+	}
+
+	return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		// Make sure the former primary email doesn't disappear.
+		err = tx.FirstOrCreate(
+			&EmailAddress{
+				UserID:      user.ID,
+				Email:       user.Email,
+				IsActivated: user.IsActive,
+			},
+			&EmailAddress{
+				UserID: user.ID,
+				Email:  user.Email,
+			},
+		).Error
+		if err != nil {
+			return errors.Wrap(err, "upsert former primary email address")
+		}
+
+		return tx.Model(&User{}).
+			Where("id = ?", user.ID).
+			Updates(map[string]any{
+				"email":        email,
+				"updated_unix": tx.NowFunc().Unix(),
+			},
+			).Error
+	})
+}
+
+func (db *users) DeleteEmail(ctx context.Context, userID int64, email string) error {
+	return db.WithContext(ctx).Where("uid = ? AND email = ?", userID, email).Delete(&EmailAddress{}).Error
+}
+
 // UserType indicates the type of the user account.
 type UserType int
 
@@ -1422,6 +1620,15 @@ func isUsernameAllowed(name string) error {
 	return isNameAllowed(reservedUsernames, reservedUsernamePatterns, name)
 }
 
+// EmailAddress is an email address of a user.
+type EmailAddress struct {
+	ID          int64  `gorm:"primaryKey"`
+	UserID      int64  `xorm:"uid INDEX NOT NULL" gorm:"column:uid;index;uniqueIndex:email_address_user_email_unique;not null"`
+	Email       string `xorm:"UNIQUE NOT NULL" gorm:"uniqueIndex:email_address_user_email_unique;not null;size:254"`
+	IsActivated bool   `gorm:"not null;default:FALSE"`
+	IsPrimary   bool   `xorm:"-" gorm:"-" json:"-"`
+}
+
 // Follow represents relations of users and their followers.
 type Follow struct {
 	ID       int64 `gorm:"primaryKey"`

+ 174 - 1
internal/db/users_test.go

@@ -116,6 +116,12 @@ func TestUsers(t *testing.T) {
 		{"SearchByName", usersSearchByName},
 		{"Update", usersUpdate},
 		{"UseCustomAvatar", usersUseCustomAvatar},
+		{"AddEmail", usersAddEmail},
+		{"GetEmail", usersGetEmail},
+		{"ListEmails", usersListEmails},
+		{"MarkEmailActivated", usersMarkEmailActivated},
+		{"MarkEmailPrimary", usersMarkEmailPrimary},
+		{"DeleteEmail", usersDeleteEmail},
 		{"Follow", usersFollow},
 		{"IsFollowing", usersIsFollowing},
 		{"Unfollow", usersUnfollow},
@@ -1100,7 +1106,19 @@ func usersUpdate(t *testing.T, db *users) {
 	})
 
 	t.Run("update email but already used", func(t *testing.T) {
-		// todo
+		bob, err := db.Create(
+			ctx,
+			"bob",
+			"[email protected]",
+			CreateUserOptions{
+				Activated: true,
+			},
+		)
+		require.NoError(t, err)
+
+		got := db.Update(ctx, alice.ID, UpdateUserOptions{Email: &bob.Email})
+		want := ErrEmailAlreadyUsed{args: errutil.Args{"email": bob.Email}}
+		assert.Equal(t, want, got)
 	})
 
 	loginSource := int64(1)
@@ -1204,6 +1222,161 @@ func TestIsUsernameAllowed(t *testing.T) {
 	}
 }
 
+func usersAddEmail(t *testing.T, db *users) {
+	ctx := context.Background()
+
+	t.Run("multiple users can add the same unverified email", func(t *testing.T) {
+		alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+		require.NoError(t, err)
+		err = db.AddEmail(ctx, alice.ID+1, "[email protected]", false)
+		require.NoError(t, err)
+	})
+
+	t.Run("only one user can add the same verified email", func(t *testing.T) {
+		bob, err := db.Create(ctx, "bob", "[email protected]", CreateUserOptions{Activated: true})
+		require.NoError(t, err)
+		got := db.AddEmail(ctx, bob.ID+1, "[email protected]", true)
+		want := ErrEmailAlreadyUsed{args: errutil.Args{"email": "[email protected]"}}
+		require.Equal(t, want, got)
+	})
+}
+
+func usersGetEmail(t *testing.T, db *users) {
+	ctx := context.Background()
+
+	const testUserID = 1
+	const testEmail = "[email protected]"
+	_, err := db.GetEmail(ctx, testUserID, testEmail, false)
+	wantErr := ErrEmailNotExist{
+		args: errutil.Args{
+			"email": testEmail,
+		},
+	}
+	assert.Equal(t, wantErr, err)
+
+	err = db.AddEmail(ctx, testUserID, testEmail, false)
+	require.NoError(t, err)
+	got, err := db.GetEmail(ctx, testUserID, testEmail, false)
+	require.NoError(t, err)
+	assert.Equal(t, testEmail, got.Email)
+
+	// Should not return if we ask for a different user
+	_, err = db.GetEmail(ctx, testUserID+1, testEmail, false)
+	assert.Equal(t, wantErr, err)
+
+	// Should not return if we only want activated emails
+	_, err = db.GetEmail(ctx, testUserID, testEmail, true)
+	assert.Equal(t, wantErr, err)
+
+	err = db.MarkEmailActivated(ctx, testUserID, testEmail)
+	require.NoError(t, err)
+	got, err = db.GetEmail(ctx, testUserID, testEmail, true)
+	require.NoError(t, err)
+	assert.Equal(t, testEmail, got.Email)
+}
+
+func usersListEmails(t *testing.T, db *users) {
+	ctx := context.Background()
+
+	t.Run("list emails with primary email", func(t *testing.T) {
+		alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+		require.NoError(t, err)
+		err = db.AddEmail(ctx, alice.ID, "[email protected]", true)
+		require.NoError(t, err)
+		err = db.MarkEmailPrimary(ctx, alice.ID, "[email protected]")
+		require.NoError(t, err)
+
+		emails, err := db.ListEmails(ctx, alice.ID)
+		require.NoError(t, err)
+		got := make([]string, 0, len(emails))
+		for _, email := range emails {
+			got = append(got, email.Email)
+		}
+		want := []string{"[email protected]", "[email protected]"}
+		assert.Equal(t, want, got)
+	})
+
+	t.Run("list emails without primary email", func(t *testing.T) {
+		bob, err := db.Create(ctx, "bob", "[email protected]", CreateUserOptions{})
+		require.NoError(t, err)
+		err = db.AddEmail(ctx, bob.ID, "[email protected]", false)
+		require.NoError(t, err)
+
+		emails, err := db.ListEmails(ctx, bob.ID)
+		require.NoError(t, err)
+		got := make([]string, 0, len(emails))
+		for _, email := range emails {
+			got = append(got, email.Email)
+		}
+		want := []string{"[email protected]", "[email protected]"}
+		assert.Equal(t, want, got)
+	})
+}
+
+func usersMarkEmailActivated(t *testing.T, db *users) {
+	ctx := context.Background()
+
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	err = db.AddEmail(ctx, alice.ID, "[email protected]", false)
+	require.NoError(t, err)
+	err = db.MarkEmailActivated(ctx, alice.ID, "[email protected]")
+	require.NoError(t, err)
+
+	gotEmail, err := db.GetEmail(ctx, alice.ID, "[email protected]", true)
+	require.NoError(t, err)
+	assert.True(t, gotEmail.IsActivated)
+
+	gotAlice, err := db.GetByID(ctx, alice.ID)
+	require.NoError(t, err)
+	assert.NotEqual(t, alice.Rands, gotAlice.Rands)
+}
+
+func usersMarkEmailPrimary(t *testing.T, db *users) {
+	ctx := context.Background()
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	err = db.AddEmail(ctx, alice.ID, "[email protected]", false)
+	require.NoError(t, err)
+
+	// Should fail because email not verified
+	gotError := db.MarkEmailPrimary(ctx, alice.ID, "[email protected]")
+	wantError := ErrEmailNotVerified{args: errutil.Args{"email": "[email protected]"}}
+	assert.Equal(t, wantError, gotError)
+
+	// Mark email as verified and should succeed
+	err = db.MarkEmailActivated(ctx, alice.ID, "[email protected]")
+	require.NoError(t, err)
+	err = db.MarkEmailPrimary(ctx, alice.ID, "[email protected]")
+	require.NoError(t, err)
+	gotAlice, err := db.GetByID(ctx, alice.ID)
+	require.NoError(t, err)
+	assert.Equal(t, "[email protected]", gotAlice.Email)
+
+	// Former primary email should be preserved
+	gotEmail, err := db.GetEmail(ctx, alice.ID, "[email protected]", false)
+	require.NoError(t, err)
+	assert.False(t, gotEmail.IsActivated)
+}
+
+func usersDeleteEmail(t *testing.T, db *users) {
+	ctx := context.Background()
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	err = db.AddEmail(ctx, alice.ID, "[email protected]", false)
+	require.NoError(t, err)
+	_, err = db.GetEmail(ctx, alice.ID, "[email protected]", false)
+	require.NoError(t, err)
+
+	err = db.DeleteEmail(ctx, alice.ID, "[email protected]")
+	require.NoError(t, err)
+	_, got := db.GetEmail(ctx, alice.ID, "[email protected]", false)
+	want := ErrEmailNotExist{args: errutil.Args{"email": "[email protected]"}}
+	require.Equal(t, want, got)
+}
+
 func usersFollow(t *testing.T, db *users) {
 	ctx := context.Background()
 

+ 26 - 34
internal/route/api/v1/user/email.go

@@ -17,7 +17,7 @@ import (
 )
 
 func ListEmails(c *context.APIContext) {
-	emails, err := db.GetEmailAddresses(c.User.ID)
+	emails, err := db.Users.ListEmails(c.Req.Context(), c.User.ID)
 	if err != nil {
 		c.Error(err, "get email addresses")
 		return
@@ -35,48 +35,40 @@ func AddEmail(c *context.APIContext, form api.CreateEmailOption) {
 		return
 	}
 
-	emails := make([]*db.EmailAddress, len(form.Emails))
-	for i := range form.Emails {
-		emails[i] = &db.EmailAddress{
-			UserID:      c.User.ID,
-			Email:       form.Emails[i],
-			IsActivated: !conf.Auth.RequireEmailConfirmation,
+	apiEmails := make([]*api.Email, 0, len(form.Emails))
+	for _, email := range form.Emails {
+		err := db.Users.AddEmail(c.Req.Context(), c.User.ID, email, !conf.Auth.RequireEmailConfirmation)
+		if err != nil {
+			if db.IsErrEmailAlreadyUsed(err) {
+				c.ErrorStatus(http.StatusUnprocessableEntity, errors.Errorf("email address has been used: %s", err.(db.ErrEmailAlreadyUsed).Email()))
+			} else {
+				c.Error(err, "add email addresses")
+			}
+			return
 		}
-	}
-
-	if err := db.AddEmailAddresses(emails); err != nil {
-		if db.IsErrEmailAlreadyUsed(err) {
-			c.ErrorStatus(http.StatusUnprocessableEntity, errors.New("email address has been used: "+err.(db.ErrEmailAlreadyUsed).Email()))
-		} else {
-			c.Error(err, "add email addresses")
-		}
-		return
-	}
 
-	apiEmails := make([]*api.Email, len(emails))
-	for i := range emails {
-		apiEmails[i] = convert.ToEmail(emails[i])
+		apiEmails = append(apiEmails,
+			&api.Email{
+				Email:    email,
+				Verified: !conf.Auth.RequireEmailConfirmation,
+			},
+		)
 	}
 	c.JSON(http.StatusCreated, &apiEmails)
 }
 
 func DeleteEmail(c *context.APIContext, form api.CreateEmailOption) {
-	if len(form.Emails) == 0 {
-		c.NoContent()
-		return
-	}
-
-	emails := make([]*db.EmailAddress, len(form.Emails))
-	for i := range form.Emails {
-		emails[i] = &db.EmailAddress{
-			UserID: c.User.ID,
-			Email:  form.Emails[i],
+	for _, email := range form.Emails {
+		if email == c.User.Email {
+			c.ErrorStatus(http.StatusBadRequest, errors.Errorf("cannot delete primary email %q", email))
+			return
 		}
-	}
 
-	if err := db.DeleteEmailAddresses(emails); err != nil {
-		c.Error(err, "delete email addresses")
-		return
+		err := db.Users.DeleteEmail(c.Req.Context(), c.User.ID, email)
+		if err != nil {
+			c.Error(err, "delete email addresses")
+			return
+		}
 	}
 	c.NoContent()
 }

+ 793 - 39
internal/route/lfs/mocks_test.go

@@ -3171,6 +3171,9 @@ func (c TwoFactorsStoreIsEnabledFuncCall) Results() []interface{} {
 // MockUsersStore is a mock implementation of the UsersStore interface (from
 // the package gogs.io/gogs/internal/db) used for unit testing.
 type MockUsersStore struct {
+	// AddEmailFunc is an instance of a mock function object controlling the
+	// behavior of the method AddEmail.
+	AddEmailFunc *UsersStoreAddEmailFunc
 	// AuthenticateFunc is an instance of a mock function object controlling
 	// the behavior of the method Authenticate.
 	AuthenticateFunc *UsersStoreAuthenticateFunc
@@ -3189,6 +3192,9 @@ type MockUsersStore struct {
 	// DeleteCustomAvatarFunc is an instance of a mock function object
 	// controlling the behavior of the method DeleteCustomAvatar.
 	DeleteCustomAvatarFunc *UsersStoreDeleteCustomAvatarFunc
+	// DeleteEmailFunc is an instance of a mock function object controlling
+	// the behavior of the method DeleteEmail.
+	DeleteEmailFunc *UsersStoreDeleteEmailFunc
 	// DeleteInactivatedFunc is an instance of a mock function object
 	// controlling the behavior of the method DeleteInactivated.
 	DeleteInactivatedFunc *UsersStoreDeleteInactivatedFunc
@@ -3207,6 +3213,9 @@ type MockUsersStore struct {
 	// GetByUsernameFunc is an instance of a mock function object
 	// controlling the behavior of the method GetByUsername.
 	GetByUsernameFunc *UsersStoreGetByUsernameFunc
+	// GetEmailFunc is an instance of a mock function object controlling the
+	// behavior of the method GetEmail.
+	GetEmailFunc *UsersStoreGetEmailFunc
 	// GetMailableEmailsByUsernamesFunc is an instance of a mock function
 	// object controlling the behavior of the method
 	// GetMailableEmailsByUsernames.
@@ -3220,12 +3229,21 @@ type MockUsersStore struct {
 	// ListFunc is an instance of a mock function object controlling the
 	// behavior of the method List.
 	ListFunc *UsersStoreListFunc
+	// ListEmailsFunc is an instance of a mock function object controlling
+	// the behavior of the method ListEmails.
+	ListEmailsFunc *UsersStoreListEmailsFunc
 	// ListFollowersFunc is an instance of a mock function object
 	// controlling the behavior of the method ListFollowers.
 	ListFollowersFunc *UsersStoreListFollowersFunc
 	// ListFollowingsFunc is an instance of a mock function object
 	// controlling the behavior of the method ListFollowings.
 	ListFollowingsFunc *UsersStoreListFollowingsFunc
+	// MarkEmailActivatedFunc is an instance of a mock function object
+	// controlling the behavior of the method MarkEmailActivated.
+	MarkEmailActivatedFunc *UsersStoreMarkEmailActivatedFunc
+	// MarkEmailPrimaryFunc is an instance of a mock function object
+	// controlling the behavior of the method MarkEmailPrimary.
+	MarkEmailPrimaryFunc *UsersStoreMarkEmailPrimaryFunc
 	// SearchByNameFunc is an instance of a mock function object controlling
 	// the behavior of the method SearchByName.
 	SearchByNameFunc *UsersStoreSearchByNameFunc
@@ -3244,6 +3262,11 @@ type MockUsersStore struct {
 // methods return zero values for all results, unless overwritten.
 func NewMockUsersStore() *MockUsersStore {
 	return &MockUsersStore{
+		AddEmailFunc: &UsersStoreAddEmailFunc{
+			defaultHook: func(context.Context, int64, string, bool) (r0 error) {
+				return
+			},
+		},
 		AuthenticateFunc: &UsersStoreAuthenticateFunc{
 			defaultHook: func(context.Context, string, string, int64) (r0 *db.User, r1 error) {
 				return
@@ -3274,6 +3297,11 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		DeleteEmailFunc: &UsersStoreDeleteEmailFunc{
+			defaultHook: func(context.Context, int64, string) (r0 error) {
+				return
+			},
+		},
 		DeleteInactivatedFunc: &UsersStoreDeleteInactivatedFunc{
 			defaultHook: func() (r0 error) {
 				return
@@ -3304,6 +3332,11 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		GetEmailFunc: &UsersStoreGetEmailFunc{
+			defaultHook: func(context.Context, int64, string, bool) (r0 *db.EmailAddress, r1 error) {
+				return
+			},
+		},
 		GetMailableEmailsByUsernamesFunc: &UsersStoreGetMailableEmailsByUsernamesFunc{
 			defaultHook: func(context.Context, []string) (r0 []string, r1 error) {
 				return
@@ -3324,6 +3357,11 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		ListEmailsFunc: &UsersStoreListEmailsFunc{
+			defaultHook: func(context.Context, int64) (r0 []*db.EmailAddress, r1 error) {
+				return
+			},
+		},
 		ListFollowersFunc: &UsersStoreListFollowersFunc{
 			defaultHook: func(context.Context, int64, int, int) (r0 []*db.User, r1 error) {
 				return
@@ -3334,6 +3372,16 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		MarkEmailActivatedFunc: &UsersStoreMarkEmailActivatedFunc{
+			defaultHook: func(context.Context, int64, string) (r0 error) {
+				return
+			},
+		},
+		MarkEmailPrimaryFunc: &UsersStoreMarkEmailPrimaryFunc{
+			defaultHook: func(context.Context, int64, string) (r0 error) {
+				return
+			},
+		},
 		SearchByNameFunc: &UsersStoreSearchByNameFunc{
 			defaultHook: func(context.Context, string, int, int, string) (r0 []*db.User, r1 int64, r2 error) {
 				return
@@ -3361,6 +3409,11 @@ func NewMockUsersStore() *MockUsersStore {
 // All methods panic on invocation, unless overwritten.
 func NewStrictMockUsersStore() *MockUsersStore {
 	return &MockUsersStore{
+		AddEmailFunc: &UsersStoreAddEmailFunc{
+			defaultHook: func(context.Context, int64, string, bool) error {
+				panic("unexpected invocation of MockUsersStore.AddEmail")
+			},
+		},
 		AuthenticateFunc: &UsersStoreAuthenticateFunc{
 			defaultHook: func(context.Context, string, string, int64) (*db.User, error) {
 				panic("unexpected invocation of MockUsersStore.Authenticate")
@@ -3391,6 +3444,11 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.DeleteCustomAvatar")
 			},
 		},
+		DeleteEmailFunc: &UsersStoreDeleteEmailFunc{
+			defaultHook: func(context.Context, int64, string) error {
+				panic("unexpected invocation of MockUsersStore.DeleteEmail")
+			},
+		},
 		DeleteInactivatedFunc: &UsersStoreDeleteInactivatedFunc{
 			defaultHook: func() error {
 				panic("unexpected invocation of MockUsersStore.DeleteInactivated")
@@ -3421,6 +3479,11 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.GetByUsername")
 			},
 		},
+		GetEmailFunc: &UsersStoreGetEmailFunc{
+			defaultHook: func(context.Context, int64, string, bool) (*db.EmailAddress, error) {
+				panic("unexpected invocation of MockUsersStore.GetEmail")
+			},
+		},
 		GetMailableEmailsByUsernamesFunc: &UsersStoreGetMailableEmailsByUsernamesFunc{
 			defaultHook: func(context.Context, []string) ([]string, error) {
 				panic("unexpected invocation of MockUsersStore.GetMailableEmailsByUsernames")
@@ -3441,6 +3504,11 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.List")
 			},
 		},
+		ListEmailsFunc: &UsersStoreListEmailsFunc{
+			defaultHook: func(context.Context, int64) ([]*db.EmailAddress, error) {
+				panic("unexpected invocation of MockUsersStore.ListEmails")
+			},
+		},
 		ListFollowersFunc: &UsersStoreListFollowersFunc{
 			defaultHook: func(context.Context, int64, int, int) ([]*db.User, error) {
 				panic("unexpected invocation of MockUsersStore.ListFollowers")
@@ -3451,6 +3519,16 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.ListFollowings")
 			},
 		},
+		MarkEmailActivatedFunc: &UsersStoreMarkEmailActivatedFunc{
+			defaultHook: func(context.Context, int64, string) error {
+				panic("unexpected invocation of MockUsersStore.MarkEmailActivated")
+			},
+		},
+		MarkEmailPrimaryFunc: &UsersStoreMarkEmailPrimaryFunc{
+			defaultHook: func(context.Context, int64, string) error {
+				panic("unexpected invocation of MockUsersStore.MarkEmailPrimary")
+			},
+		},
 		SearchByNameFunc: &UsersStoreSearchByNameFunc{
 			defaultHook: func(context.Context, string, int, int, string) ([]*db.User, int64, error) {
 				panic("unexpected invocation of MockUsersStore.SearchByName")
@@ -3478,6 +3556,9 @@ func NewStrictMockUsersStore() *MockUsersStore {
 // All methods delegate to the given implementation, unless overwritten.
 func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 	return &MockUsersStore{
+		AddEmailFunc: &UsersStoreAddEmailFunc{
+			defaultHook: i.AddEmail,
+		},
 		AuthenticateFunc: &UsersStoreAuthenticateFunc{
 			defaultHook: i.Authenticate,
 		},
@@ -3496,6 +3577,9 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		DeleteCustomAvatarFunc: &UsersStoreDeleteCustomAvatarFunc{
 			defaultHook: i.DeleteCustomAvatar,
 		},
+		DeleteEmailFunc: &UsersStoreDeleteEmailFunc{
+			defaultHook: i.DeleteEmail,
+		},
 		DeleteInactivatedFunc: &UsersStoreDeleteInactivatedFunc{
 			defaultHook: i.DeleteInactivated,
 		},
@@ -3514,6 +3598,9 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		GetByUsernameFunc: &UsersStoreGetByUsernameFunc{
 			defaultHook: i.GetByUsername,
 		},
+		GetEmailFunc: &UsersStoreGetEmailFunc{
+			defaultHook: i.GetEmail,
+		},
 		GetMailableEmailsByUsernamesFunc: &UsersStoreGetMailableEmailsByUsernamesFunc{
 			defaultHook: i.GetMailableEmailsByUsernames,
 		},
@@ -3526,12 +3613,21 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		ListFunc: &UsersStoreListFunc{
 			defaultHook: i.List,
 		},
+		ListEmailsFunc: &UsersStoreListEmailsFunc{
+			defaultHook: i.ListEmails,
+		},
 		ListFollowersFunc: &UsersStoreListFollowersFunc{
 			defaultHook: i.ListFollowers,
 		},
 		ListFollowingsFunc: &UsersStoreListFollowingsFunc{
 			defaultHook: i.ListFollowings,
 		},
+		MarkEmailActivatedFunc: &UsersStoreMarkEmailActivatedFunc{
+			defaultHook: i.MarkEmailActivated,
+		},
+		MarkEmailPrimaryFunc: &UsersStoreMarkEmailPrimaryFunc{
+			defaultHook: i.MarkEmailPrimary,
+		},
 		SearchByNameFunc: &UsersStoreSearchByNameFunc{
 			defaultHook: i.SearchByName,
 		},
@@ -3547,6 +3643,117 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 	}
 }
 
+// UsersStoreAddEmailFunc describes the behavior when the AddEmail method of
+// the parent MockUsersStore instance is invoked.
+type UsersStoreAddEmailFunc struct {
+	defaultHook func(context.Context, int64, string, bool) error
+	hooks       []func(context.Context, int64, string, bool) error
+	history     []UsersStoreAddEmailFuncCall
+	mutex       sync.Mutex
+}
+
+// AddEmail delegates to the next hook function in the queue and stores the
+// parameter and result values of this invocation.
+func (m *MockUsersStore) AddEmail(v0 context.Context, v1 int64, v2 string, v3 bool) error {
+	r0 := m.AddEmailFunc.nextHook()(v0, v1, v2, v3)
+	m.AddEmailFunc.appendCall(UsersStoreAddEmailFuncCall{v0, v1, v2, v3, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the AddEmail method of
+// the parent MockUsersStore instance is invoked and the hook queue is
+// empty.
+func (f *UsersStoreAddEmailFunc) SetDefaultHook(hook func(context.Context, int64, string, bool) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// AddEmail method of the parent MockUsersStore instance invokes the hook at
+// the front of the queue and discards it. After the queue is empty, the
+// default hook function is invoked for any future action.
+func (f *UsersStoreAddEmailFunc) PushHook(hook func(context.Context, int64, string, bool) error) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreAddEmailFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, string, bool) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreAddEmailFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, string, bool) error {
+		return r0
+	})
+}
+
+func (f *UsersStoreAddEmailFunc) nextHook() func(context.Context, int64, string, bool) error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreAddEmailFunc) appendCall(r0 UsersStoreAddEmailFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreAddEmailFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreAddEmailFunc) History() []UsersStoreAddEmailFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreAddEmailFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreAddEmailFuncCall is an object that describes an invocation of
+// method AddEmail on an instance of MockUsersStore.
+type UsersStoreAddEmailFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Arg2 is the value of the 3rd argument passed to this method
+	// invocation.
+	Arg2 string
+	// Arg3 is the value of the 4th argument passed to this method
+	// invocation.
+	Arg3 bool
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreAddEmailFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2, c.Arg3}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreAddEmailFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // UsersStoreAuthenticateFunc describes the behavior when the Authenticate
 // method of the parent MockUsersStore instance is invoked.
 type UsersStoreAuthenticateFunc struct {
@@ -4197,6 +4404,114 @@ func (c UsersStoreDeleteCustomAvatarFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0}
 }
 
+// UsersStoreDeleteEmailFunc describes the behavior when the DeleteEmail
+// method of the parent MockUsersStore instance is invoked.
+type UsersStoreDeleteEmailFunc struct {
+	defaultHook func(context.Context, int64, string) error
+	hooks       []func(context.Context, int64, string) error
+	history     []UsersStoreDeleteEmailFuncCall
+	mutex       sync.Mutex
+}
+
+// DeleteEmail delegates to the next hook function in the queue and stores
+// the parameter and result values of this invocation.
+func (m *MockUsersStore) DeleteEmail(v0 context.Context, v1 int64, v2 string) error {
+	r0 := m.DeleteEmailFunc.nextHook()(v0, v1, v2)
+	m.DeleteEmailFunc.appendCall(UsersStoreDeleteEmailFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the DeleteEmail method
+// of the parent MockUsersStore instance is invoked and the hook queue is
+// empty.
+func (f *UsersStoreDeleteEmailFunc) SetDefaultHook(hook func(context.Context, int64, string) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// DeleteEmail method of the parent MockUsersStore instance invokes the hook
+// at the front of the queue and discards it. After the queue is empty, the
+// default hook function is invoked for any future action.
+func (f *UsersStoreDeleteEmailFunc) PushHook(hook func(context.Context, int64, string) error) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreDeleteEmailFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, string) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreDeleteEmailFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, string) error {
+		return r0
+	})
+}
+
+func (f *UsersStoreDeleteEmailFunc) nextHook() func(context.Context, int64, string) error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreDeleteEmailFunc) appendCall(r0 UsersStoreDeleteEmailFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreDeleteEmailFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreDeleteEmailFunc) History() []UsersStoreDeleteEmailFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreDeleteEmailFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreDeleteEmailFuncCall is an object that describes an invocation
+// of method DeleteEmail on an instance of MockUsersStore.
+type UsersStoreDeleteEmailFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Arg2 is the value of the 3rd argument passed to this method
+	// invocation.
+	Arg2 string
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreDeleteEmailFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreDeleteEmailFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // UsersStoreDeleteInactivatedFunc describes the behavior when the
 // DeleteInactivated method of the parent MockUsersStore instance is
 // invoked.
@@ -4836,37 +5151,35 @@ func (c UsersStoreGetByUsernameFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1}
 }
 
-// UsersStoreGetMailableEmailsByUsernamesFunc describes the behavior when
-// the GetMailableEmailsByUsernames method of the parent MockUsersStore
-// instance is invoked.
-type UsersStoreGetMailableEmailsByUsernamesFunc struct {
-	defaultHook func(context.Context, []string) ([]string, error)
-	hooks       []func(context.Context, []string) ([]string, error)
-	history     []UsersStoreGetMailableEmailsByUsernamesFuncCall
+// UsersStoreGetEmailFunc describes the behavior when the GetEmail method of
+// the parent MockUsersStore instance is invoked.
+type UsersStoreGetEmailFunc struct {
+	defaultHook func(context.Context, int64, string, bool) (*db.EmailAddress, error)
+	hooks       []func(context.Context, int64, string, bool) (*db.EmailAddress, error)
+	history     []UsersStoreGetEmailFuncCall
 	mutex       sync.Mutex
 }
 
-// GetMailableEmailsByUsernames delegates to the next hook function in the
-// queue and stores the parameter and result values of this invocation.
-func (m *MockUsersStore) GetMailableEmailsByUsernames(v0 context.Context, v1 []string) ([]string, error) {
-	r0, r1 := m.GetMailableEmailsByUsernamesFunc.nextHook()(v0, v1)
-	m.GetMailableEmailsByUsernamesFunc.appendCall(UsersStoreGetMailableEmailsByUsernamesFuncCall{v0, v1, r0, r1})
+// GetEmail delegates to the next hook function in the queue and stores the
+// parameter and result values of this invocation.
+func (m *MockUsersStore) GetEmail(v0 context.Context, v1 int64, v2 string, v3 bool) (*db.EmailAddress, error) {
+	r0, r1 := m.GetEmailFunc.nextHook()(v0, v1, v2, v3)
+	m.GetEmailFunc.appendCall(UsersStoreGetEmailFuncCall{v0, v1, v2, v3, r0, r1})
 	return r0, r1
 }
 
-// SetDefaultHook sets function that is called when the
-// GetMailableEmailsByUsernames method of the parent MockUsersStore instance
-// is invoked and the hook queue is empty.
-func (f *UsersStoreGetMailableEmailsByUsernamesFunc) SetDefaultHook(hook func(context.Context, []string) ([]string, error)) {
+// SetDefaultHook sets function that is called when the GetEmail method of
+// the parent MockUsersStore instance is invoked and the hook queue is
+// empty.
+func (f *UsersStoreGetEmailFunc) SetDefaultHook(hook func(context.Context, int64, string, bool) (*db.EmailAddress, error)) {
 	f.defaultHook = hook
 }
 
 // PushHook adds a function to the end of hook queue. Each invocation of the
-// GetMailableEmailsByUsernames method of the parent MockUsersStore instance
-// invokes the hook at the front of the queue and discards it. After the
-// queue is empty, the default hook function is invoked for any future
-// action.
-func (f *UsersStoreGetMailableEmailsByUsernamesFunc) PushHook(hook func(context.Context, []string) ([]string, error)) {
+// GetEmail method of the parent MockUsersStore instance invokes the hook at
+// the front of the queue and discards it. After the queue is empty, the
+// default hook function is invoked for any future action.
+func (f *UsersStoreGetEmailFunc) PushHook(hook func(context.Context, int64, string, bool) (*db.EmailAddress, error)) {
 	f.mutex.Lock()
 	f.hooks = append(f.hooks, hook)
 	f.mutex.Unlock()
@@ -4874,20 +5187,20 @@ func (f *UsersStoreGetMailableEmailsByUsernamesFunc) PushHook(hook func(context.
 
 // SetDefaultReturn calls SetDefaultHook with a function that returns the
 // given values.
-func (f *UsersStoreGetMailableEmailsByUsernamesFunc) SetDefaultReturn(r0 []string, r1 error) {
-	f.SetDefaultHook(func(context.Context, []string) ([]string, error) {
+func (f *UsersStoreGetEmailFunc) SetDefaultReturn(r0 *db.EmailAddress, r1 error) {
+	f.SetDefaultHook(func(context.Context, int64, string, bool) (*db.EmailAddress, error) {
 		return r0, r1
 	})
 }
 
 // PushReturn calls PushHook with a function that returns the given values.
-func (f *UsersStoreGetMailableEmailsByUsernamesFunc) PushReturn(r0 []string, r1 error) {
-	f.PushHook(func(context.Context, []string) ([]string, error) {
+func (f *UsersStoreGetEmailFunc) PushReturn(r0 *db.EmailAddress, r1 error) {
+	f.PushHook(func(context.Context, int64, string, bool) (*db.EmailAddress, error) {
 		return r0, r1
 	})
 }
 
-func (f *UsersStoreGetMailableEmailsByUsernamesFunc) nextHook() func(context.Context, []string) ([]string, error) {
+func (f *UsersStoreGetEmailFunc) nextHook() func(context.Context, int64, string, bool) (*db.EmailAddress, error) {
 	f.mutex.Lock()
 	defer f.mutex.Unlock()
 
@@ -4900,36 +5213,152 @@ func (f *UsersStoreGetMailableEmailsByUsernamesFunc) nextHook() func(context.Con
 	return hook
 }
 
-func (f *UsersStoreGetMailableEmailsByUsernamesFunc) appendCall(r0 UsersStoreGetMailableEmailsByUsernamesFuncCall) {
+func (f *UsersStoreGetEmailFunc) appendCall(r0 UsersStoreGetEmailFuncCall) {
 	f.mutex.Lock()
 	f.history = append(f.history, r0)
 	f.mutex.Unlock()
 }
 
-// History returns a sequence of
-// UsersStoreGetMailableEmailsByUsernamesFuncCall objects describing the
-// invocations of this function.
-func (f *UsersStoreGetMailableEmailsByUsernamesFunc) History() []UsersStoreGetMailableEmailsByUsernamesFuncCall {
+// History returns a sequence of UsersStoreGetEmailFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreGetEmailFunc) History() []UsersStoreGetEmailFuncCall {
 	f.mutex.Lock()
-	history := make([]UsersStoreGetMailableEmailsByUsernamesFuncCall, len(f.history))
+	history := make([]UsersStoreGetEmailFuncCall, len(f.history))
 	copy(history, f.history)
 	f.mutex.Unlock()
 
 	return history
 }
 
-// UsersStoreGetMailableEmailsByUsernamesFuncCall is an object that
-// describes an invocation of method GetMailableEmailsByUsernames on an
-// instance of MockUsersStore.
-type UsersStoreGetMailableEmailsByUsernamesFuncCall struct {
+// UsersStoreGetEmailFuncCall is an object that describes an invocation of
+// method GetEmail on an instance of MockUsersStore.
+type UsersStoreGetEmailFuncCall struct {
 	// Arg0 is the value of the 1st argument passed to this method
 	// invocation.
 	Arg0 context.Context
 	// Arg1 is the value of the 2nd argument passed to this method
 	// invocation.
-	Arg1 []string
-	// Result0 is the value of the 1st result returned from this method
-	// invocation.
+	Arg1 int64
+	// Arg2 is the value of the 3rd argument passed to this method
+	// invocation.
+	Arg2 string
+	// Arg3 is the value of the 4th argument passed to this method
+	// invocation.
+	Arg3 bool
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 *db.EmailAddress
+	// Result1 is the value of the 2nd result returned from this method
+	// invocation.
+	Result1 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreGetEmailFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2, c.Arg3}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreGetEmailFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0, c.Result1}
+}
+
+// UsersStoreGetMailableEmailsByUsernamesFunc describes the behavior when
+// the GetMailableEmailsByUsernames method of the parent MockUsersStore
+// instance is invoked.
+type UsersStoreGetMailableEmailsByUsernamesFunc struct {
+	defaultHook func(context.Context, []string) ([]string, error)
+	hooks       []func(context.Context, []string) ([]string, error)
+	history     []UsersStoreGetMailableEmailsByUsernamesFuncCall
+	mutex       sync.Mutex
+}
+
+// GetMailableEmailsByUsernames delegates to the next hook function in the
+// queue and stores the parameter and result values of this invocation.
+func (m *MockUsersStore) GetMailableEmailsByUsernames(v0 context.Context, v1 []string) ([]string, error) {
+	r0, r1 := m.GetMailableEmailsByUsernamesFunc.nextHook()(v0, v1)
+	m.GetMailableEmailsByUsernamesFunc.appendCall(UsersStoreGetMailableEmailsByUsernamesFuncCall{v0, v1, r0, r1})
+	return r0, r1
+}
+
+// SetDefaultHook sets function that is called when the
+// GetMailableEmailsByUsernames method of the parent MockUsersStore instance
+// is invoked and the hook queue is empty.
+func (f *UsersStoreGetMailableEmailsByUsernamesFunc) SetDefaultHook(hook func(context.Context, []string) ([]string, error)) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// GetMailableEmailsByUsernames method of the parent MockUsersStore instance
+// invokes the hook at the front of the queue and discards it. After the
+// queue is empty, the default hook function is invoked for any future
+// action.
+func (f *UsersStoreGetMailableEmailsByUsernamesFunc) PushHook(hook func(context.Context, []string) ([]string, error)) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreGetMailableEmailsByUsernamesFunc) SetDefaultReturn(r0 []string, r1 error) {
+	f.SetDefaultHook(func(context.Context, []string) ([]string, error) {
+		return r0, r1
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreGetMailableEmailsByUsernamesFunc) PushReturn(r0 []string, r1 error) {
+	f.PushHook(func(context.Context, []string) ([]string, error) {
+		return r0, r1
+	})
+}
+
+func (f *UsersStoreGetMailableEmailsByUsernamesFunc) nextHook() func(context.Context, []string) ([]string, error) {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreGetMailableEmailsByUsernamesFunc) appendCall(r0 UsersStoreGetMailableEmailsByUsernamesFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of
+// UsersStoreGetMailableEmailsByUsernamesFuncCall objects describing the
+// invocations of this function.
+func (f *UsersStoreGetMailableEmailsByUsernamesFunc) History() []UsersStoreGetMailableEmailsByUsernamesFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreGetMailableEmailsByUsernamesFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreGetMailableEmailsByUsernamesFuncCall is an object that
+// describes an invocation of method GetMailableEmailsByUsernames on an
+// instance of MockUsersStore.
+type UsersStoreGetMailableEmailsByUsernamesFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 []string
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
 	Result0 []string
 	// Result1 is the value of the 2nd result returned from this method
 	// invocation.
@@ -5274,6 +5703,114 @@ func (c UsersStoreListFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1}
 }
 
+// UsersStoreListEmailsFunc describes the behavior when the ListEmails
+// method of the parent MockUsersStore instance is invoked.
+type UsersStoreListEmailsFunc struct {
+	defaultHook func(context.Context, int64) ([]*db.EmailAddress, error)
+	hooks       []func(context.Context, int64) ([]*db.EmailAddress, error)
+	history     []UsersStoreListEmailsFuncCall
+	mutex       sync.Mutex
+}
+
+// ListEmails delegates to the next hook function in the queue and stores
+// the parameter and result values of this invocation.
+func (m *MockUsersStore) ListEmails(v0 context.Context, v1 int64) ([]*db.EmailAddress, error) {
+	r0, r1 := m.ListEmailsFunc.nextHook()(v0, v1)
+	m.ListEmailsFunc.appendCall(UsersStoreListEmailsFuncCall{v0, v1, r0, r1})
+	return r0, r1
+}
+
+// SetDefaultHook sets function that is called when the ListEmails method of
+// the parent MockUsersStore instance is invoked and the hook queue is
+// empty.
+func (f *UsersStoreListEmailsFunc) SetDefaultHook(hook func(context.Context, int64) ([]*db.EmailAddress, error)) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// ListEmails method of the parent MockUsersStore instance invokes the hook
+// at the front of the queue and discards it. After the queue is empty, the
+// default hook function is invoked for any future action.
+func (f *UsersStoreListEmailsFunc) PushHook(hook func(context.Context, int64) ([]*db.EmailAddress, error)) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreListEmailsFunc) SetDefaultReturn(r0 []*db.EmailAddress, r1 error) {
+	f.SetDefaultHook(func(context.Context, int64) ([]*db.EmailAddress, error) {
+		return r0, r1
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreListEmailsFunc) PushReturn(r0 []*db.EmailAddress, r1 error) {
+	f.PushHook(func(context.Context, int64) ([]*db.EmailAddress, error) {
+		return r0, r1
+	})
+}
+
+func (f *UsersStoreListEmailsFunc) nextHook() func(context.Context, int64) ([]*db.EmailAddress, error) {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreListEmailsFunc) appendCall(r0 UsersStoreListEmailsFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreListEmailsFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreListEmailsFunc) History() []UsersStoreListEmailsFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreListEmailsFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreListEmailsFuncCall is an object that describes an invocation of
+// method ListEmails on an instance of MockUsersStore.
+type UsersStoreListEmailsFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 []*db.EmailAddress
+	// Result1 is the value of the 2nd result returned from this method
+	// invocation.
+	Result1 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreListEmailsFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreListEmailsFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0, c.Result1}
+}
+
 // UsersStoreListFollowersFunc describes the behavior when the ListFollowers
 // method of the parent MockUsersStore instance is invoked.
 type UsersStoreListFollowersFunc struct {
@@ -5502,6 +6039,223 @@ func (c UsersStoreListFollowingsFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1}
 }
 
+// UsersStoreMarkEmailActivatedFunc describes the behavior when the
+// MarkEmailActivated method of the parent MockUsersStore instance is
+// invoked.
+type UsersStoreMarkEmailActivatedFunc struct {
+	defaultHook func(context.Context, int64, string) error
+	hooks       []func(context.Context, int64, string) error
+	history     []UsersStoreMarkEmailActivatedFuncCall
+	mutex       sync.Mutex
+}
+
+// MarkEmailActivated delegates to the next hook function in the queue and
+// stores the parameter and result values of this invocation.
+func (m *MockUsersStore) MarkEmailActivated(v0 context.Context, v1 int64, v2 string) error {
+	r0 := m.MarkEmailActivatedFunc.nextHook()(v0, v1, v2)
+	m.MarkEmailActivatedFunc.appendCall(UsersStoreMarkEmailActivatedFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the MarkEmailActivated
+// method of the parent MockUsersStore instance is invoked and the hook
+// queue is empty.
+func (f *UsersStoreMarkEmailActivatedFunc) SetDefaultHook(hook func(context.Context, int64, string) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// MarkEmailActivated method of the parent MockUsersStore instance invokes
+// the hook at the front of the queue and discards it. After the queue is
+// empty, the default hook function is invoked for any future action.
+func (f *UsersStoreMarkEmailActivatedFunc) PushHook(hook func(context.Context, int64, string) error) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreMarkEmailActivatedFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, string) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreMarkEmailActivatedFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, string) error {
+		return r0
+	})
+}
+
+func (f *UsersStoreMarkEmailActivatedFunc) nextHook() func(context.Context, int64, string) error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreMarkEmailActivatedFunc) appendCall(r0 UsersStoreMarkEmailActivatedFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreMarkEmailActivatedFuncCall
+// objects describing the invocations of this function.
+func (f *UsersStoreMarkEmailActivatedFunc) History() []UsersStoreMarkEmailActivatedFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreMarkEmailActivatedFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreMarkEmailActivatedFuncCall is an object that describes an
+// invocation of method MarkEmailActivated on an instance of MockUsersStore.
+type UsersStoreMarkEmailActivatedFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Arg2 is the value of the 3rd argument passed to this method
+	// invocation.
+	Arg2 string
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreMarkEmailActivatedFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreMarkEmailActivatedFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
+// UsersStoreMarkEmailPrimaryFunc describes the behavior when the
+// MarkEmailPrimary method of the parent MockUsersStore instance is invoked.
+type UsersStoreMarkEmailPrimaryFunc struct {
+	defaultHook func(context.Context, int64, string) error
+	hooks       []func(context.Context, int64, string) error
+	history     []UsersStoreMarkEmailPrimaryFuncCall
+	mutex       sync.Mutex
+}
+
+// MarkEmailPrimary delegates to the next hook function in the queue and
+// stores the parameter and result values of this invocation.
+func (m *MockUsersStore) MarkEmailPrimary(v0 context.Context, v1 int64, v2 string) error {
+	r0 := m.MarkEmailPrimaryFunc.nextHook()(v0, v1, v2)
+	m.MarkEmailPrimaryFunc.appendCall(UsersStoreMarkEmailPrimaryFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the MarkEmailPrimary
+// method of the parent MockUsersStore instance is invoked and the hook
+// queue is empty.
+func (f *UsersStoreMarkEmailPrimaryFunc) SetDefaultHook(hook func(context.Context, int64, string) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// MarkEmailPrimary method of the parent MockUsersStore instance invokes the
+// hook at the front of the queue and discards it. After the queue is empty,
+// the default hook function is invoked for any future action.
+func (f *UsersStoreMarkEmailPrimaryFunc) PushHook(hook func(context.Context, int64, string) error) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreMarkEmailPrimaryFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, string) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreMarkEmailPrimaryFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, string) error {
+		return r0
+	})
+}
+
+func (f *UsersStoreMarkEmailPrimaryFunc) nextHook() func(context.Context, int64, string) error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreMarkEmailPrimaryFunc) appendCall(r0 UsersStoreMarkEmailPrimaryFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreMarkEmailPrimaryFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreMarkEmailPrimaryFunc) History() []UsersStoreMarkEmailPrimaryFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreMarkEmailPrimaryFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreMarkEmailPrimaryFuncCall is an object that describes an
+// invocation of method MarkEmailPrimary on an instance of MockUsersStore.
+type UsersStoreMarkEmailPrimaryFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Arg2 is the value of the 3rd argument passed to this method
+	// invocation.
+	Arg2 string
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreMarkEmailPrimaryFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreMarkEmailPrimaryFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // UsersStoreSearchByNameFunc describes the behavior when the SearchByName
 // method of the parent MockUsersStore instance is invoked.
 type UsersStoreSearchByNameFunc struct {

+ 4 - 2
internal/route/user/auth.go

@@ -445,7 +445,7 @@ func verifyActiveEmailCode(code, email string) *db.EmailAddress {
 		data := com.ToStr(user.ID) + email + user.LowerName + user.Password + user.Rands
 
 		if tool.VerifyTimeLimitCode(data, minutes, prefix) {
-			emailAddress, err := db.EmailAddresses.GetByEmail(gocontext.TODO(), email, false)
+			emailAddress, err := db.Users.GetEmail(gocontext.TODO(), user.ID, email, false)
 			if err == nil {
 				return emailAddress
 			}
@@ -515,8 +515,10 @@ func ActivateEmail(c *context.Context) {
 
 	// Verify code.
 	if email := verifyActiveEmailCode(code, emailAddr); email != nil {
-		if err := email.Activate(); err != nil {
+		err := db.Users.MarkEmailActivated(c.Req.Context(), email.UserID, email.Email)
+		if err != nil {
 			c.Error(err, "activate email")
+			return
 		}
 
 		log.Trace("Email activated: %s", email.Email)

+ 20 - 17
internal/route/user/setting.go

@@ -223,7 +223,7 @@ func SettingsEmails(c *context.Context) {
 	c.Title("settings.emails")
 	c.PageIs("SettingsEmails")
 
-	emails, err := db.GetEmailAddresses(c.User.ID)
+	emails, err := db.Users.ListEmails(c.Req.Context(), c.User.ID)
 	if err != nil {
 		c.Errorf(err, "get email addresses")
 		return
@@ -237,9 +237,9 @@ func SettingsEmailPost(c *context.Context, f form.AddEmail) {
 	c.Title("settings.emails")
 	c.PageIs("SettingsEmails")
 
-	// Make emailaddress primary.
 	if c.Query("_method") == "PRIMARY" {
-		if err := db.MakeEmailPrimary(c.UserID(), &db.EmailAddress{ID: c.QueryInt64("id")}); err != nil {
+		err := db.Users.MarkEmailPrimary(c.Req.Context(), c.User.ID, c.Query("email"))
+		if err != nil {
 			c.Errorf(err, "make email primary")
 			return
 		}
@@ -249,7 +249,7 @@ func SettingsEmailPost(c *context.Context, f form.AddEmail) {
 	}
 
 	// Add Email address.
-	emails, err := db.GetEmailAddresses(c.User.ID)
+	emails, err := db.Users.ListEmails(c.Req.Context(), c.User.ID)
 	if err != nil {
 		c.Errorf(err, "get email addresses")
 		return
@@ -261,12 +261,8 @@ func SettingsEmailPost(c *context.Context, f form.AddEmail) {
 		return
 	}
 
-	emailAddr := &db.EmailAddress{
-		UserID:      c.User.ID,
-		Email:       f.Email,
-		IsActivated: !conf.Auth.RequireEmailConfirmation,
-	}
-	if err := db.AddEmailAddress(emailAddr); err != nil {
+	err = db.Users.AddEmail(c.Req.Context(), c.User.ID, f.Email, !conf.Auth.RequireEmailConfirmation)
+	if err != nil {
 		if db.IsErrEmailAlreadyUsed(err) {
 			c.RenderWithErr(c.Tr("form.email_been_used"), SETTINGS_EMAILS, &f)
 		} else {
@@ -277,12 +273,12 @@ func SettingsEmailPost(c *context.Context, f form.AddEmail) {
 
 	// Send confirmation email
 	if conf.Auth.RequireEmailConfirmation {
-		email.SendActivateEmailMail(c.Context, db.NewMailerUser(c.User), emailAddr.Email)
+		email.SendActivateEmailMail(c.Context, db.NewMailerUser(c.User), f.Email)
 
 		if err := c.Cache.Put("MailResendLimit_"+c.User.LowerName, c.User.LowerName, 180); err != nil {
 			log.Error("Set cache 'MailResendLimit' failed: %v", err)
 		}
-		c.Flash.Info(c.Tr("settings.add_email_confirmation_sent", emailAddr.Email, conf.Auth.ActivateCodeLives/60))
+		c.Flash.Info(c.Tr("settings.add_email_confirmation_sent", f.Email, conf.Auth.ActivateCodeLives/60))
 	} else {
 		c.Flash.Success(c.Tr("settings.add_email_success"))
 	}
@@ -291,11 +287,18 @@ func SettingsEmailPost(c *context.Context, f form.AddEmail) {
 }
 
 func DeleteEmail(c *context.Context) {
-	if err := db.DeleteEmailAddress(&db.EmailAddress{
-		ID:     c.QueryInt64("id"),
-		UserID: c.User.ID,
-	}); err != nil {
-		c.Errorf(err, "delete email address")
+	email := c.Query("id") // The "id" here is the actual email address
+	if c.User.Email == email {
+		c.Flash.Error(c.Tr("settings.email_deletion_primary"))
+		c.JSONSuccess(map[string]any{
+			"redirect": conf.Server.Subpath + "/user/settings/email",
+		})
+		return
+	}
+
+	err := db.Users.DeleteEmail(c.Req.Context(), c.User.ID, email)
+	if err != nil {
+		c.Error(err, "delete email address")
 		return
 	}
 

+ 2 - 2
templates/user/settings/email.tmpl

@@ -20,7 +20,7 @@
 									{{if .IsPrimary}}<span class="ui green tiny primary label">{{$.i18n.Tr "settings.primary"}}</span>{{end}}
 									{{if not .IsPrimary}}
 										<div class="ui right">
-											<button class="ui red tiny basic button delete-button" data-url="{{$.Link}}/delete" data-id="{{.ID}}">
+											<button class="ui red tiny basic button delete-button" data-url="{{$.Link}}/delete" data-id="{{.Email}}">
 												{{$.i18n.Tr "settings.delete_email"}}
 											</button>
 										</div>
@@ -29,7 +29,7 @@
 												<form action="{{$.Link}}" method="post">
 													{{$.CSRFTokenHTML}}
 													<input name="_method" type="hidden" value="PRIMARY">
-													<input name="id" type="hidden" value="{{.ID}}">
+													<input name="email" type="hidden" value="{{.Email}}">
 													<button class="ui green tiny basic button">{{$.i18n.Tr "settings.primary_email"}}</button>
 												</form>
 											</div>