Detailed changes
@@ -26,6 +26,7 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
var m models.User
var pks []ssh.PublicKey
var hl models.Handle
+ var ems []proto.UserEmail
if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
var err error
m, err = d.store.FindUserByUsername(ctx, tx, username)
@@ -38,6 +39,15 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
return err
}
+ emails, err := d.store.ListUserEmails(ctx, tx, m.ID)
+ if err != nil {
+ return err
+ }
+
+ for _, e := range emails {
+ ems = append(ems, &userEmail{e})
+ }
+
hl, err = d.store.GetHandleByUserID(ctx, tx, m.ID)
return err
}); err != nil {
@@ -53,6 +63,7 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
user: m,
publicKeys: pks,
handle: hl,
+ emails: ems,
}, nil
}
@@ -61,6 +72,7 @@ func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) {
var m models.User
var pks []ssh.PublicKey
var hl models.Handle
+ var ems []proto.UserEmail
if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
var err error
m, err = d.store.GetUserByID(ctx, tx, id)
@@ -73,6 +85,15 @@ func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) {
return err
}
+ emails, err := d.store.ListUserEmails(ctx, tx, m.ID)
+ if err != nil {
+ return err
+ }
+
+ for _, e := range emails {
+ ems = append(ems, &userEmail{e})
+ }
+
hl, err = d.store.GetHandleByUserID(ctx, tx, m.ID)
return err
}); err != nil {
@@ -88,6 +109,7 @@ func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) {
user: m,
publicKeys: pks,
handle: hl,
+ emails: ems,
}, nil
}
@@ -98,6 +120,7 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
var m models.User
var pks []ssh.PublicKey
var hl models.Handle
+ var ems []proto.UserEmail
if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
var err error
m, err = d.store.FindUserByPublicKey(ctx, tx, pk)
@@ -110,6 +133,15 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
return err
}
+ emails, err := d.store.ListUserEmails(ctx, tx, m.ID)
+ if err != nil {
+ return err
+ }
+
+ for _, e := range emails {
+ ems = append(ems, &userEmail{e})
+ }
+
hl, err = d.store.GetHandleByUserID(ctx, tx, m.ID)
return err
}); err != nil {
@@ -125,6 +157,7 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
user: m,
publicKeys: pks,
handle: hl,
+ emails: ems,
}, nil
}
@@ -134,6 +167,7 @@ func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.Us
var m models.User
var pks []ssh.PublicKey
var hl models.Handle
+ var ems []proto.UserEmail
token = HashToken(token)
if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
@@ -156,6 +190,15 @@ func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.Us
return err
}
+ emails, err := d.store.ListUserEmails(ctx, tx, m.ID)
+ if err != nil {
+ return err
+ }
+
+ for _, e := range emails {
+ ems = append(ems, &userEmail{e})
+ }
+
hl, err = d.store.GetHandleByUserID(ctx, tx, m.ID)
return err
}); err != nil {
@@ -171,6 +214,7 @@ func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.Us
user: m,
publicKeys: pks,
handle: hl,
+ emails: ems,
}, nil
}
@@ -228,7 +272,7 @@ func (d *Backend) AddPublicKey(ctx context.Context, username string, pk ssh.Publ
// It implements backend.Backend.
func (d *Backend) CreateUser(ctx context.Context, username string, opts proto.UserOptions) (proto.User, error) {
if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
- return d.store.CreateUser(ctx, tx, username, opts.Admin, opts.PublicKeys)
+ return d.store.CreateUser(ctx, tx, username, opts.Admin, opts.PublicKeys, opts.Emails)
}); err != nil {
return nil, db.WrapError(err)
}
@@ -335,10 +379,60 @@ func (d *Backend) SetPassword(ctx context.Context, username string, rawPassword
)
}
+// AddUserEmail adds an email to a user.
+func (d *Backend) AddUserEmail(ctx context.Context, user proto.User, email string) error {
+ return db.WrapError(
+ d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+ return d.store.AddUserEmail(ctx, tx, user.ID(), email, false)
+ }),
+ )
+}
+
+// ListUserEmails lists the emails of a user.
+func (d *Backend) ListUserEmails(ctx context.Context, user proto.User) ([]proto.UserEmail, error) {
+ var ems []proto.UserEmail
+ if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+ emails, err := d.store.ListUserEmails(ctx, tx, user.ID())
+ if err != nil {
+ return err
+ }
+
+ for _, e := range emails {
+ ems = append(ems, &userEmail{e})
+ }
+
+ return nil
+ }); err != nil {
+ return nil, db.WrapError(err)
+ }
+
+ return ems, nil
+}
+
+// RemoveUserEmail deletes an email for a user.
+// The deleted email must not be the primary email.
+func (d *Backend) RemoveUserEmail(ctx context.Context, user proto.User, email string) error {
+ return db.WrapError(
+ d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+ return d.store.RemoveUserEmail(ctx, tx, user.ID(), email)
+ }),
+ )
+}
+
+// SetUserPrimaryEmail sets the primary email of a user.
+func (d *Backend) SetUserPrimaryEmail(ctx context.Context, user proto.User, email string) error {
+ return db.WrapError(
+ d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+ return d.store.SetUserPrimaryEmail(ctx, tx, user.ID(), email)
+ }),
+ )
+}
+
type user struct {
user models.User
publicKeys []ssh.PublicKey
handle models.Handle
+ emails []proto.UserEmail
}
var _ proto.User = (*user)(nil)
@@ -371,3 +465,29 @@ func (u *user) Password() string {
return ""
}
+
+// Emails implements proto.User.
+func (u *user) Emails() []proto.UserEmail {
+ return u.emails
+}
+
+type userEmail struct {
+ email models.UserEmail
+}
+
+var _ proto.UserEmail = (*userEmail)(nil)
+
+// Email implements proto.UserEmail.
+func (e *userEmail) Email() string {
+ return e.email.Email
+}
+
+// ID implements proto.UserEmail.
+func (e *userEmail) ID() int64 {
+ return e.email.ID
+}
+
+// IsPrimary implements proto.UserEmail.
+func (e *userEmail) IsPrimary() bool {
+ return e.email.IsPrimary
+}
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS user_emails (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
email TEXT NOT NULL UNIQUE,
- is_primary BOOLEAN NOT NULL,
+ is_primary BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL,
CONSTRAINT user_id_fk
@@ -78,6 +78,9 @@ CREATE TABLE IF NOT EXISTS user_emails (
ON UPDATE CASCADE
);
+-- Create unique index for primary email
+CREATE UNIQUE INDEX user_emails_user_id_is_primary_idx ON user_emails (user_id) WHERE is_primary;
+
-- Add name to users table
ALTER TABLE users ADD COLUMN name TEXT;
@@ -112,7 +115,7 @@ ALTER TABLE repos ADD CONSTRAINT org_id_fk
ALTER TABLE repos ALTER COLUMN user_id DROP NOT NULL;
-- Check that both user_id and org_id can't be null
-ALTER TABLE repos ADD CONSTRAINT user_id_org_id_not_null CHECK (user_id IS NULL <> org_id IS NULL);
+ALTER TABLE repos ADD CONSTRAINT user_id_org_id_not_null CHECK ((user_id IS NULL) <> (org_id IS NULL));
-- Add team_id to collabs table
ALTER TABLE collabs ADD COLUMN team_id INTEGER;
@@ -125,7 +128,7 @@ ALTER TABLE collabs ADD CONSTRAINT team_id_fk
ALTER TABLE collabs ALTER COLUMN user_id DROP NOT NULL;
-- Check that both user_id and team_id can't be null
-ALTER TABLE collabs ADD CONSTRAINT user_id_team_id_not_null CHECK (user_id IS NULL <> team_id IS NULL);
+ALTER TABLE collabs ADD CONSTRAINT user_id_team_id_not_null CHECK ((user_id IS NULL) <> (team_id IS NULL));
-- Alter unique constraint on collabs table
ALTER TABLE collabs DROP CONSTRAINT collabs_user_id_repo_id_key;
@@ -71,7 +71,7 @@ CREATE TABLE IF NOT EXISTS user_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email TEXT NOT NULL UNIQUE,
- is_primary BOOLEAN NOT NULL,
+ is_primary BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL,
CONSTRAINT user_id_fk
@@ -80,6 +80,9 @@ CREATE TABLE IF NOT EXISTS user_emails (
ON UPDATE CASCADE
);
+-- Create unique index for primary email
+CREATE UNIQUE INDEX user_emails_user_id_is_primary_idx ON user_emails (user_id) WHERE is_primary;
+
ALTER TABLE users RENAME TO _users_old;
CREATE TABLE IF NOT EXISTS users (
@@ -14,6 +14,8 @@ type User interface {
PublicKeys() []ssh.PublicKey
// Password returns the user's password hash.
Password() string
+ // Emails returns the user's emails.
+ Emails() []UserEmail
}
// UserOptions are options for creating a user.
@@ -22,4 +24,19 @@ type UserOptions struct {
Admin bool
// PublicKeys are the user's public keys.
PublicKeys []ssh.PublicKey
+ // Emails are the user's emails.
+ // The first email in the slice will be set as the user's primary email.
+ Emails []string
+}
+
+// UserEmail represents a user's email address.
+type UserEmail interface {
+ // ID returns the email's ID.
+ ID() int64
+
+ // Email returns the email address.
+ Email() string
+
+ // IsPrimary returns whether the email is the user's primary email.
+ IsPrimary() bool
}
@@ -33,7 +33,7 @@ func OrgCommand() *cobra.Command {
Use: "list",
Short: "List organizations",
Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
be := backend.FromContext(ctx)
user := proto.UserFromContext(ctx)
@@ -22,9 +22,9 @@ func UserCommand() *cobra.Command {
var admin bool
var key string
userCreateCommand := &cobra.Command{
- Use: "create USERNAME",
+ Use: "create USERNAME [EMAIL]",
Short: "Create a new user",
- Args: cobra.ExactArgs(1),
+ Args: cobra.MinimumNArgs(1),
PersistentPreRunE: checkIfAdmin,
RunE: func(cmd *cobra.Command, args []string) error {
var pubkeys []ssh.PublicKey
@@ -45,6 +45,10 @@ func UserCommand() *cobra.Command {
PublicKeys: pubkeys,
}
+ if len(args) > 1 {
+ opts.Emails = append(opts.Emails, args[1])
+ }
+
_, err := be.CreateUser(ctx, username, opts)
return err
},
@@ -166,6 +170,14 @@ func UserCommand() *cobra.Command {
cmd.Printf(" %s\n", sshutils.MarshalAuthorizedKey(pk))
}
+ emails := user.Emails()
+ if len(emails) > 0 {
+ cmd.Printf("Emails:\n")
+ for _, e := range emails {
+ cmd.Printf(" %s (primary: %v)\n", e.Email(), e.IsPrimary())
+ }
+ }
+
return nil
},
}
@@ -185,6 +197,63 @@ func UserCommand() *cobra.Command {
},
}
+ userAddEmailCommand := &cobra.Command{
+ Use: "add-email USERNAME EMAIL",
+ Short: "Add an email to a user",
+ Args: cobra.ExactArgs(2),
+ PersistentPreRunE: checkIfAdmin,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ be := backend.FromContext(ctx)
+ username := args[0]
+ email := args[1]
+ u, err := be.User(ctx, username)
+ if err != nil {
+ return err
+ }
+
+ return be.AddUserEmail(ctx, u, email)
+ },
+ }
+
+ userRemoveEmailCommand := &cobra.Command{
+ Use: "remove-email USERNAME EMAIL",
+ Short: "Remove an email from a user",
+ Args: cobra.ExactArgs(2),
+ PersistentPreRunE: checkIfAdmin,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ be := backend.FromContext(ctx)
+ username := args[0]
+ email := args[1]
+ u, err := be.User(ctx, username)
+ if err != nil {
+ return err
+ }
+
+ return be.RemoveUserEmail(ctx, u, email)
+ },
+ }
+
+ userSetPrimaryEmailCommand := &cobra.Command{
+ Use: "set-primary-email USERNAME EMAIL",
+ Short: "Set a user's primary email",
+ Args: cobra.ExactArgs(2),
+ PersistentPreRunE: checkIfAdmin,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ be := backend.FromContext(ctx)
+ username := args[0]
+ email := args[1]
+ u, err := be.User(ctx, username)
+ if err != nil {
+ return err
+ }
+
+ return be.SetUserPrimaryEmail(ctx, u, email)
+ },
+ }
+
cmd.AddCommand(
userCreateCommand,
userAddPubkeyCommand,
@@ -194,6 +263,9 @@ func UserCommand() *cobra.Command {
userRemovePubkeyCommand,
userSetAdminCommand,
userSetUsernameCommand,
+ userAddEmailCommand,
+ userRemoveEmailCommand,
+ userSetPrimaryEmailCommand,
)
return cmd
@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/soft-serve/pkg/db"
"github.com/charmbracelet/soft-serve/pkg/db/models"
"github.com/charmbracelet/soft-serve/pkg/store"
+ "github.com/charmbracelet/soft-serve/pkg/utils"
)
var _ store.OrgStore = (*orgStore)(nil)
@@ -15,6 +16,10 @@ type orgStore struct{ *handleStore }
// UpdateOrgContactEmail implements store.OrgStore.
func (*orgStore) UpdateOrgContactEmail(ctx context.Context, h db.Handler, org int64, email string) error {
+ if err := utils.ValidateEmail(email); err != nil {
+ return err
+ }
+
query := h.Rebind(`
UPDATE organizations
SET
@@ -58,6 +63,10 @@ func (s *orgStore) DeleteOrgByID(ctx context.Context, h db.Handler, user, id int
// Create implements store.OrgStore.
func (s *orgStore) CreateOrg(ctx context.Context, h db.Handler, user int64, name, email string) (models.Organization, error) {
+ if err := utils.ValidateEmail(email); err != nil {
+ return models.Organization{}, err
+ }
+
handle, err := s.CreateHandle(ctx, h, name)
if err != nil {
return models.Organization{}, err
@@ -2,6 +2,7 @@ package database
import (
"context"
+ "fmt"
"strings"
"github.com/charmbracelet/soft-serve/pkg/db"
@@ -39,7 +40,7 @@ func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx db.Handler, use
}
// CreateUser implements store.UserStore.
-func (s *userStore) CreateUser(ctx context.Context, tx db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error {
+func (s *userStore) CreateUser(ctx context.Context, tx db.Handler, username string, isAdmin bool, pks []ssh.PublicKey, emails []string) error {
handleID, err := s.CreateHandle(ctx, tx, username)
if err != nil {
return err
@@ -71,6 +72,12 @@ func (s *userStore) CreateUser(ctx context.Context, tx db.Handler, username stri
}
}
+ for i, e := range emails {
+ if err := s.AddUserEmail(ctx, tx, userID, e, i == 0); err != nil {
+ return err
+ }
+ }
+
return nil
}
@@ -255,6 +262,9 @@ func (*userStore) SetUserPasswordByUsername(ctx context.Context, tx db.Handler,
// AddUserEmail implements store.UserStore.
func (*userStore) AddUserEmail(ctx context.Context, tx db.Handler, userID int64, email string, isPrimary bool) error {
+ if err := utils.ValidateEmail(email); err != nil {
+ return err
+ }
query := tx.Rebind(`INSERT INTO user_emails (user_id, email, is_primary, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP);`)
_, err := tx.ExecContext(ctx, query, userID, email, isPrimary)
@@ -269,16 +279,40 @@ func (*userStore) ListUserEmails(ctx context.Context, tx db.Handler, userID int6
return ms, err
}
-// UpdateUserEmail implements store.UserStore.
-func (*userStore) UpdateUserEmail(ctx context.Context, tx db.Handler, userID int64, oldEmail string, newEmail string, isPrimary bool) error {
- query := tx.Rebind(`UPDATE user_emails SET email = ?, is_primary = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND email = ?;`)
- _, err := tx.ExecContext(ctx, query, newEmail, isPrimary, userID, oldEmail)
- return err
+// RemoveUserEmail implements store.UserStore.
+func (*userStore) RemoveUserEmail(ctx context.Context, tx db.Handler, userID int64, email string) error {
+ var e models.UserEmail
+ query := tx.Rebind(`DELETE FROM user_emails WHERE user_id = ? AND email = ? RETURNING *;`)
+ if err := tx.GetContext(ctx, &e, query, userID, email); err != nil {
+ return err
+ }
+
+ if e.IsPrimary {
+ return fmt.Errorf("cannot remove primary email")
+ } else if e.ID == 0 {
+ return db.ErrRecordNotFound
+ }
+
+ return nil
}
-// DeleteUserEmail implements store.UserStore.
-func (*userStore) DeleteUserEmail(ctx context.Context, tx db.Handler, userID int64, email string) error {
- query := tx.Rebind(`DELETE FROM user_emails WHERE user_id = ? AND email = ?;`)
- _, err := tx.ExecContext(ctx, query, userID, email)
- return err
+// SetUserPrimaryEmail implements store.UserStore.
+func (*userStore) SetUserPrimaryEmail(ctx context.Context, tx db.Handler, userID int64, email string) error {
+ query := tx.Rebind(`UPDATE user_emails SET is_primary = FALSE WHERE user_id = ?;`)
+ _, err := tx.ExecContext(ctx, query, userID)
+ if err != nil {
+ return err
+ }
+
+ var emailID int64
+ query = tx.Rebind(`UPDATE user_emails SET is_primary = TRUE WHERE user_id = ? AND email = ? RETURNING id;`)
+ if err := tx.GetContext(ctx, &emailID, query, userID, email); err != nil {
+ return err
+ }
+
+ if emailID == 0 {
+ return db.ErrRecordNotFound
+ }
+
+ return nil
}
@@ -15,7 +15,7 @@ type UserStore interface {
FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error)
FindUserByAccessToken(ctx context.Context, h db.Handler, token string) (models.User, error)
GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error)
- CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error
+ CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey, emails []string) error
DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error
SetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error
SetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error
@@ -28,6 +28,6 @@ type UserStore interface {
AddUserEmail(ctx context.Context, h db.Handler, userID int64, email string, isPrimary bool) error
ListUserEmails(ctx context.Context, h db.Handler, userID int64) ([]models.UserEmail, error)
- UpdateUserEmail(ctx context.Context, h db.Handler, userID int64, oldEmail string, newEmail string, isPrimary bool) error
- DeleteUserEmail(ctx context.Context, h db.Handler, userID int64, email string) error
+ RemoveUserEmail(ctx context.Context, h db.Handler, userID int64, email string) error
+ SetUserPrimaryEmail(ctx context.Context, h db.Handler, userID int64, email string) error
}
@@ -1,12 +1,20 @@
package utils
import (
+ "errors"
"fmt"
+ "net/mail"
"path"
"strings"
"unicode"
)
+var (
+
+ // ErrInvalidEmail indicates that an email address is invalid.
+ ErrInvalidEmail = errors.New("invalid email address")
+)
+
// SanitizeRepo returns a sanitized version of the given repository name.
func SanitizeRepo(repo string) string {
repo = strings.TrimPrefix(repo, "/")
@@ -50,3 +58,17 @@ func ValidateRepo(repo string) error {
return nil
}
+
+// ValidateEmail returns an error if the given email address is invalid.
+func ValidateEmail(email string) error {
+ if strings.ContainsAny(email, " <>") {
+ return ErrInvalidEmail
+ }
+
+ _, err := mail.ParseAddress(email)
+ if err != nil {
+ return fmt.Errorf("%w: %s", ErrInvalidEmail, err)
+ }
+
+ return nil
+}
@@ -76,6 +76,7 @@ func TestScript(t *testing.T) {
key, admin1 := mkkey("admin1")
_, admin2 := mkkey("admin2")
_, user1 := mkkey("user1")
+ _, user2 := mkkey("user2")
testscript.Run(t, testscript.Params{
Dir: "./testdata/",
@@ -117,6 +118,7 @@ func TestScript(t *testing.T) {
e.Setenv("ADMIN1_AUTHORIZED_KEY", admin1.AuthorizedKey())
e.Setenv("ADMIN2_AUTHORIZED_KEY", admin2.AuthorizedKey())
e.Setenv("USER1_AUTHORIZED_KEY", user1.AuthorizedKey())
+ e.Setenv("USER2_AUTHORIZED_KEY", user2.AuthorizedKey())
e.Setenv("SSH_KNOWN_HOSTS_FILE", filepath.Join(t.TempDir(), "known_hosts"))
e.Setenv("SSH_KNOWN_CONFIG_FILE", filepath.Join(t.TempDir(), "config"))
@@ -1,7 +1,7 @@
# vi: set ft=conf
# convert crlf to lf on windows
-[windows] dos2unix info.txt admin_key_list1.txt admin_key_list2.txt list1.txt list2.txt foo_info1.txt foo_info2.txt foo_info3.txt foo_info4.txt foo_info5.txt
+[windows] dos2unix info.txt admin_key_list1.txt admin_key_list2.txt list1.txt list2.txt foo_info1.txt foo_info2.txt foo_info3.txt foo_info4.txt foo_info5.txt bar_info.txt
# start soft serve
exec soft serve &
@@ -68,6 +68,38 @@ soft user delete foo2
soft user list
cmpenv stdout list1.txt
+# create a new user with an invalid email
+! soft user create bar --key "$USER2_AUTHORIZED_KEY" "foobar"
+stderr 'invalid email address.*'
+
+# create a new user with a valid email
+soft user create bar --key "$USER2_AUTHORIZED_KEY" "foo@bar.baz"
+! stdout .
+# add email to existing user
+soft user add-email bar "foobar@fubar.baz"
+! stdout .
+# add existing email
+! soft user add-email bar "foobar@fubar.baz"
+stderr 'duplicate key.*'
+
+# get new user info
+soft user info bar
+cmpenv stdout bar_info.txt
+
+# remove primary email from user
+! soft user remove-email bar "foo@bar.baz"
+stderr 'cannot remove primary email.*'
+
+# set primary email that doesn't exist
+! soft user set-primary-email bar "foobar@foofoo.foo"
+stderr 'no rows in result set.*'
+# set primary email
+soft user set-primary-email bar "foobar@fubar.baz"
+! stdout .
+# remove other email
+soft user remove-email bar "foo@bar.baz"
+! stdout .
+
# stop the server
[windows] stopserver
[windows] ! stderr .
@@ -112,3 +144,11 @@ $ADMIN1_AUTHORIZED_KEY
$ADMIN2_AUTHORIZED_KEY
-- admin_key_list2.txt --
$ADMIN1_AUTHORIZED_KEY
+-- bar_info.txt --
+Username: bar
+Admin: false
+Public keys:
+ $USER2_AUTHORIZED_KEY
+Emails:
+ foo@bar.baz (primary: true)
+ foobar@fubar.baz (primary: false)