diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index 5c7c6da80660dca820c63633d74335e9932152ae..81e3c5bb8f6e34147c91d447c437711d1133c86c 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -388,10 +388,19 @@ recover_interval      = 60
 ; Default: Two weeks
 recover_hash_lifetime = 1209600
 
+; Allow users to set change their email address (if supported by Auth method).
+; When turning this on, it is also strongly recommended to turn on verify_email
+; below.
+change_email = false
+
 ; Allow users to set change their passwords (if supported by Auth method)
 change_password = true
 
-; Force users to verify their email address before being able to log in (only if method=Database)
+; Force users to verify their email address before being able to log in
+; (only if method=Database) or make changes to it (if change_email=true).
+; If you wish to customize the email messages used by the system, see the
+; translation strings starting with verify and change_notification, as well as
+; the notify-email-change.phtml and verify-email.phtml Email templates.
 verify_email = false
 
 ; Set this to false if you would like to store catalog passwords in plain text
@@ -1770,7 +1779,7 @@ treeSearchLimit = 100
 ; Use * for all supported forms
 ; Note: when "feedback" is active, Captcha can be conditionally disabled on a
 ;       form-by-form basis with the useCaptcha setting in FeedbackForms.yaml.
-;forms = changePassword, email, newAccount, passwordRecovery, sms
+;forms = changeEmail, changePassword, email, newAccount, passwordRecovery, sms
 
 
 ; This section can be used to display default text inside the search boxes, useful
diff --git a/languages/en.ini b/languages/en.ini
index 696d693c15bbd9a589275b19645190ca63c75824..fc56a294e6f7d4a9c10f9a81a1a7e9136d7b4a0c 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -174,7 +174,12 @@ Catalog Login = "Catalog Login"
 Catalog Results = "Catalog Results"
 catalog_login_desc = "Enter your library catalog credentials."
 CD = "CD"
+Change Email Address = "Change Email Address"
 Change Password = "Change Password"
+change_email_disabled = "You are not allowed to change your email address at this time"
+change_email_verification_reminder = "Submitting this form will send an email to the new address; you will have to click on a link in the email before the change will take effect."
+change_notification_email_message = "A request was just made to change your email address at %%library%%. If you did not initiate this request, you may wish to log in at %%url%% and confirm the integrity of your account. Please contact support at %%email%% if you have questions or concerns."
+change_notification_email_subject = "Account Email Change Notification"
 channel_add_more = "Add more channels like this"
 channel_browse = "Browse more records"
 channel_expand = "Explore related channels"
@@ -325,6 +330,7 @@ Email address is invalid = "Email address is invalid"
 Email Record = "Email Record"
 Email this = "Email this"
 Email this Search = "Email this Search"
+email_change_pending_html = "You have a pending email change to %%pending%%. Please click the link in the verification email sent to this address to complete the change. If necessary, we can <a href="%%url%%">Resend the Verification Email</a>."
 email_failure = "Error - Message Cannot Be Sent"
 email_link = "Link"
 email_maximum_recipients_note = "At most %%max%% recipients are allowed."
@@ -668,6 +674,7 @@ New Item Search = "New Item Search"
 New Item Search Results = "New Item Search Results"
 New Items = "New Items"
 New Title = "New Title"
+new_email_success = "Your email address has been changed successfully"
 new_password = "New Password"
 new_password_success = "Your password has successfully been changed"
 new_user_welcome_subject = "Your new account at %%library%%"
@@ -1168,6 +1175,7 @@ Username = "Username"
 Username cannot be blank = "Username cannot be blank"
 Username is already in use in another library card = "Username is already in use in another library card"
 verification_done = "Your email address has been verified successfully."
+verification_email_change_sent = "Email address verification instructions have been sent to the new email address. You must verify the address before the change will take effect."
 verification_email_notification = "A request was just made to verify your email address for your account with %%library%%."
 verification_email_sent = "Email address verification instructions have been sent to the email address registered with this account."
 verification_email_subject = "VuFind Email Verification"
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index f568416df0653d3d772428fc341bad5fbc442fd6..b413c471867c5d49913691b117fd21598cc1beb2 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -627,9 +627,10 @@ $staticRoutes = [
     'LibGuides/Home', 'LibGuides/Results',
     'LibraryCards/Home', 'LibraryCards/SelectCard',
     'LibraryCards/DeleteCard',
-    'MyResearch/Account', 'MyResearch/ChangePassword', 'MyResearch/CheckedOut',
-    'MyResearch/Delete', 'MyResearch/DeleteAccount', 'MyResearch/DeleteList',
-    'MyResearch/Edit', 'MyResearch/Email', 'MyResearch/EmailNotVerified', 'MyResearch/Favorites',
+    'MyResearch/Account', 'MyResearch/ChangeEmail', 'MyResearch/ChangePassword',
+    'MyResearch/CheckedOut', 'MyResearch/Delete', 'MyResearch/DeleteAccount',
+    'MyResearch/DeleteList', 'MyResearch/Edit', 'MyResearch/Email',
+    'MyResearch/EmailNotVerified', 'MyResearch/Favorites',
     'MyResearch/Fines', 'MyResearch/HistoricLoans', 'MyResearch/Holds',
     'MyResearch/Home', 'MyResearch/ILLRequests', 'MyResearch/Logout',
     'MyResearch/NewPassword', 'MyResearch/Profile',
diff --git a/module/VuFind/sql/migrations/pgsql/6.1/001-modify-user-columns.sql b/module/VuFind/sql/migrations/pgsql/6.1/001-modify-user-columns.sql
new file mode 100644
index 0000000000000000000000000000000000000000..347c60e01b15f4921ab2ecc492fc71d824ea5933
--- /dev/null
+++ b/module/VuFind/sql/migrations/pgsql/6.1/001-modify-user-columns.sql
@@ -0,0 +1,9 @@
+--
+-- Modifications to table `user`
+--
+
+ALTER TABLE "user"
+  ADD COLUMN pending_email varchar(255) NOT NULL DEFAULT '';
+
+ALTER TABLE "user"
+  ADD COLUMN user_provided_email boolean NOT NULL DEFAULT '0';
diff --git a/module/VuFind/sql/mysql.sql b/module/VuFind/sql/mysql.sql
index f500d8e70aec0d65a4eb780b7e099b729c3ab503..3b1e0285172eb5602e85bcc2a30789cafa4b9988 100644
--- a/module/VuFind/sql/mysql.sql
+++ b/module/VuFind/sql/mysql.sql
@@ -205,6 +205,8 @@ CREATE TABLE `user` (
   `lastname` varchar(50) NOT NULL DEFAULT '',
   `email` varchar(255) NOT NULL DEFAULT '',
   `email_verified` datetime DEFAULT NULL,
+  `pending_email` varchar(255) NOT NULL DEFAULT '',
+  `user_provided_email` tinyint(1) NOT NULL DEFAULT '0',
   `cat_id` varchar(255) DEFAULT NULL,
   `cat_username` varchar(50) DEFAULT NULL,
   `cat_password` varchar(70) DEFAULT NULL,
diff --git a/module/VuFind/sql/pgsql.sql b/module/VuFind/sql/pgsql.sql
index e31d908e4e8b568ff2824a596c876b8ef3942df8..20f0a0f53c03dca6215e24adfafdcc4774ea1208 100644
--- a/module/VuFind/sql/pgsql.sql
+++ b/module/VuFind/sql/pgsql.sql
@@ -131,6 +131,8 @@ firstname varchar(50) NOT NULL DEFAULT '',
 lastname varchar(50) NOT NULL DEFAULT '',
 email varchar(255) NOT NULL DEFAULT '',
 email_verified timestamp DEFAULT NULL,
+pending_email varchar(255) NOT NULL DEFAULT '',
+user_provided_email boolean NOT NULL DEFAULT '0',
 cat_id varchar(255) DEFAULT NULL,
 cat_username varchar(50) DEFAULT NULL,
 cat_password varchar(70) DEFAULT NULL,
diff --git a/module/VuFind/src/VuFind/Auth/CAS.php b/module/VuFind/src/VuFind/Auth/CAS.php
index 5d7b2d60ffc04dbc9e075c121f55a43d58603176..341ea989e42adb3c10af8e2668d90eebea9f2588 100644
--- a/module/VuFind/src/VuFind/Auth/CAS.php
+++ b/module/VuFind/src/VuFind/Auth/CAS.php
@@ -142,7 +142,9 @@ class CAS extends AbstractBase
         foreach ($attribsToCheck as $attribute) {
             if (isset($cas->$attribute)) {
                 $value = $casauth->getAttribute($cas->$attribute);
-                if ($attribute != 'cat_password') {
+                if ($attribute == 'email') {
+                    $user->updateEmail($value);
+                } elseif ($attribute != 'cat_password') {
                     $user->$attribute = ($value === null) ? '' : $value;
                 } else {
                     $catPassword = $value;
diff --git a/module/VuFind/src/VuFind/Auth/Database.php b/module/VuFind/src/VuFind/Auth/Database.php
index 4c9df10bc2effe3eb83f62691ffad2bdb9cd50fc..f81a82eae6140528a2d5e1ad158262bb2394b068 100644
--- a/module/VuFind/src/VuFind/Auth/Database.php
+++ b/module/VuFind/src/VuFind/Auth/Database.php
@@ -29,6 +29,7 @@
  */
 namespace VuFind\Auth;
 
+use VuFind\Db\Row\User;
 use VuFind\Db\Table\User as UserTable;
 use VuFind\Exception\Auth as AuthException;
 use VuFind\Exception\AuthEmailNotVerified as AuthEmailNotVerifiedException;
@@ -68,7 +69,7 @@ class Database extends AbstractBase
      * @param Request $request Request object containing account credentials.
      *
      * @throws AuthException
-     * @return \VuFind\Db\Row\User Object representing logged-in user.
+     * @return User Object representing logged-in user.
      */
     public function authenticate($request)
     {
@@ -110,7 +111,7 @@ class Database extends AbstractBase
      * @param Request $request Request object containing new account details.
      *
      * @throws AuthException
-     * @return \VuFind\Db\Row\User New user row.
+     * @return User New user row.
      */
     public function create($request)
     {
@@ -142,7 +143,7 @@ class Database extends AbstractBase
      * @param Request $request Request object containing new account details.
      *
      * @throws AuthException
-     * @return \VuFind\Db\Row\User New user row.
+     * @return User New user row.
      */
     public function updatePassword($request)
     {
@@ -201,7 +202,7 @@ class Database extends AbstractBase
      * Check if the user's email address has been verified (if necessary) and
      * throws exception if not.
      *
-     * @param \VuFind\Db\Row\User $user User to check
+     * @param User $user User to check
      *
      * @return void
      * @throws AuthEmailNotVerifiedException
@@ -387,14 +388,14 @@ class Database extends AbstractBase
      * @param string[]  $params Parameters returned from collectParamsFromRequest()
      * @param UserTable $table  The VuFind user table
      *
-     * @return \VuFind\Db\Row\User A user row object
+     * @return User A user row object
      */
     protected function createUserFromParams($params, $table)
     {
         $user = $table->createRowForUsername($params['username']);
         $user->firstname = $params['firstname'];
         $user->lastname = $params['lastname'];
-        $user->email = $params['email'];
+        $user->updateEmail($params['email'], true);
         if ($this->passwordHashingEnabled()) {
             $bcrypt = new Bcrypt();
             $user->pass_hash = $bcrypt->create($params['password']);
diff --git a/module/VuFind/src/VuFind/Auth/Facebook.php b/module/VuFind/src/VuFind/Auth/Facebook.php
index 2ca46ee2410629d553ef70b464900018d3596e7e..c63b2bef1cae6b959c5beeb80cf6cb27ff7ce8cf 100644
--- a/module/VuFind/src/VuFind/Auth/Facebook.php
+++ b/module/VuFind/src/VuFind/Auth/Facebook.php
@@ -121,7 +121,7 @@ class Facebook extends AbstractBase implements
             $user->lastname = $details->last_name;
         }
         if (isset($details->email)) {
-            $user->email = $details->email;
+            $user->updateEmail($details->email);
         }
 
         // Save and return the user object:
diff --git a/module/VuFind/src/VuFind/Auth/ILS.php b/module/VuFind/src/VuFind/Auth/ILS.php
index c39b3aeb6624b5a4a00ac7452e3a4fca14341a61..8eee1714527140cb0527915bab9cd8948c5745e4 100644
--- a/module/VuFind/src/VuFind/Auth/ILS.php
+++ b/module/VuFind/src/VuFind/Auth/ILS.php
@@ -241,10 +241,11 @@ class ILS extends AbstractBase
         $user->password = '';
 
         // Update user information based on ILS data:
-        $fields = ['firstname', 'lastname', 'email', 'major', 'college'];
+        $fields = ['firstname', 'lastname', 'major', 'college'];
         foreach ($fields as $field) {
             $user->$field = $info[$field] ?? ' ';
         }
+        $user->updateEmail($info['email']);
 
         // Update the user in the database, then return it to the caller:
         $user->saveCredentials(
diff --git a/module/VuFind/src/VuFind/Auth/Manager.php b/module/VuFind/src/VuFind/Auth/Manager.php
index 2722f1dd0274dd9ed71a9fa86b7481363856ff44..611aef87ece45f185ea1f248d8c1fc0f0b13ed7c 100644
--- a/module/VuFind/src/VuFind/Auth/Manager.php
+++ b/module/VuFind/src/VuFind/Auth/Manager.php
@@ -209,11 +209,24 @@ class Manager implements \ZfcRbac\Identity\IdentityProviderInterface
             && $this->getAuth($authMethod)->supportsPasswordRecovery();
     }
 
+    /**
+     * Is email changing currently allowed?
+     *
+     * @param string $authMethod optional; check this auth method rather than
+     * the one in config file
+     *
+     * @return bool
+     */
+    public function supportsEmailChange($authMethod = null)
+    {
+        return $this->config->Authentication->change_email ?? false;
+    }
+
     /**
      * Is new passwords currently allowed?
      *
      * @param string $authMethod optional; check this auth method rather than
-     *  the one in config file
+     * the one in config file
      *
      * @return bool
      */
@@ -551,6 +564,30 @@ class Manager implements \ZfcRbac\Identity\IdentityProviderInterface
         return $user;
     }
 
+    /**
+     * Update a user's email from the request.
+     *
+     * @param UserRow $user  Object representing user being updated.
+     * @param string  $email New email address to set (must be pre-validated!).
+     *
+     * @throws AuthException
+     * @return void
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function updateEmail(UserRow $user, $email)
+    {
+        // Depending on verification setting, either do a direct update or else
+        // put the new address into a pending state.
+        if ($this->config->Authentication->verify_email ?? false) {
+            $user->pending_email = $email;
+        } else {
+            $user->updateEmail($email, true);
+        }
+        $user->save();
+        $this->updateSession($user);
+    }
+
     /**
      * Try to log in the user using current query parameters; return User object
      * on success, throws exception on failure.
diff --git a/module/VuFind/src/VuFind/Auth/Shibboleth.php b/module/VuFind/src/VuFind/Auth/Shibboleth.php
index 2e490709de48d59fe127d9b6a25e226ebed556a2..63d5f33e607b190bb0baabf0a1f20137fdab69fc 100644
--- a/module/VuFind/src/VuFind/Auth/Shibboleth.php
+++ b/module/VuFind/src/VuFind/Auth/Shibboleth.php
@@ -142,7 +142,9 @@ class Shibboleth extends AbstractBase
         foreach ($attribsToCheck as $attribute) {
             if (isset($shib->$attribute)) {
                 $value = $request->getServer()->get($shib->$attribute);
-                if ($attribute != 'cat_password') {
+                if ($attribute == 'email') {
+                    $user->updateEmail($value);
+                } elseif ($attribute != 'cat_password') {
                     $user->$attribute = ($value === null) ? '' : $value;
                 } else {
                     $catPassword = $value;
diff --git a/module/VuFind/src/VuFind/Controller/AlmaController.php b/module/VuFind/src/VuFind/Controller/AlmaController.php
index 32d3864ff7c97f2002b6a8bc89ca38ea0192b201..3753d63c18f63a03d8373589ea9212e8ca7f5bbf 100644
--- a/module/VuFind/src/VuFind/Controller/AlmaController.php
+++ b/module/VuFind/src/VuFind/Controller/AlmaController.php
@@ -238,7 +238,7 @@ class AlmaController extends AbstractBase
                 $user->username = $username;
                 $user->firstname = $firstname;
                 $user->lastname = $lastname;
-                $user->email = $email;
+                $user->updateEmail($email);
                 $user->cat_id = $primaryId;
                 $user->cat_username = $username;
 
diff --git a/module/VuFind/src/VuFind/Controller/MyResearchController.php b/module/VuFind/src/VuFind/Controller/MyResearchController.php
index 30d7d1e1f1823a890e0bae5cd9adeb7ccee54011..3eae2a3fe1ad6e1020af1f026a13d6a7d360e10f 100644
--- a/module/VuFind/src/VuFind/Controller/MyResearchController.php
+++ b/module/VuFind/src/VuFind/Controller/MyResearchController.php
@@ -982,9 +982,17 @@ class MyResearchController extends AbstractBase
     {
         if ($this->params()->fromQuery('reverify')) {
             $table = $this->getTable('User');
+            // Case 1: new user:
             $user = $table
                 ->getByUsername($this->getUserVerificationContainer()->user, false);
-            $this->sendVerificationEmail($user);
+            // Case 2: pending email change:
+            if (!$user) {
+                $user = $this->getUser();
+                if (!empty($user->pending_email)) {
+                    $change = true;
+                }
+            }
+            $this->sendVerificationEmail($user, $change ?? false);
         } else {
             $this->flashMessenger()->addMessage('verification_email_sent', 'info');
         }
@@ -1571,13 +1579,46 @@ class MyResearchController extends AbstractBase
     }
 
     /**
-     * Send a verify email message.
+     * When a request to change a user's email address has been received, we should
+     * send a notification to the old email address for the user's information.
      *
      * @param \VuFind\Db\Row\User $user User object we're recovering
      *
      * @return void (sends email or adds error message)
      */
-    protected function sendVerificationEmail($user)
+    protected function sendChangeNotificationEmail($user)
+    {
+        $config = $this->getConfig();
+        $renderer = $this->getViewRenderer();
+        // Custom template for emails (text-only)
+        $message = $renderer->render(
+            'Email/notify-email-change.phtml',
+            [
+                'library' => $config->Site->title,
+                'url' => $this->getServerUrl('home'),
+                'email' => $config->Site->email,
+            ]
+        );
+        // If the user is setting up a new account, use the main email
+        // address; if they have a pending address change, use that.
+        $this->serviceLocator->get('VuFind\Mailer\Mailer')->send(
+            $user->email,
+            $config->Site->email,
+            $this->translate('change_notification_email_subject'),
+            $message
+        );
+    }
+
+    /**
+     * Send a verify email message.
+     *
+     * @param \VuFind\Db\Row\User $user   User object we're recovering
+     * @param bool                $change Is the user changing their email (true)
+     * or setting up a new account (false).
+     *
+     * @return void (sends email or adds error message)
+     */
+    protected function sendVerificationEmail($user, $change = false)
     {
         // If we can't find a user
         if (null == $user) {
@@ -1588,7 +1629,7 @@ class MyResearchController extends AbstractBase
             $hashtime = $this->getHashAge($user->verify_hash);
             $recoveryInterval = $this->getConfig()->Authentication->recover_interval
                 ?? 60;
-            if (time() - $hashtime < $recoveryInterval) {
+            if (time() - $hashtime < $recoveryInterval && !$change) {
                 $this->flashMessenger()
                     ->addMessage('verification_too_soon', 'error');
             } else {
@@ -1609,14 +1650,25 @@ class MyResearchController extends AbstractBase
                                 . $user->verify_hash . '&auth_method=' . $method
                         ]
                     );
+                    // If the user is setting up a new account, use the main email
+                    // address; if they have a pending address change, use that.
+                    $to = empty($user->pending_email)
+                        ? $user->email : $user->pending_email;
                     $this->serviceLocator->get('VuFind\Mailer\Mailer')->send(
-                        $user->email,
+                        $to,
                         $config->Site->email,
                         $this->translate('verification_email_subject'),
                         $message
                     );
-                    $this->flashMessenger()
-                        ->addMessage('verification_email_sent', 'info');
+                    $flashMessage = $change
+                        ? 'verification_email_change_sent'
+                        : 'verification_email_sent';
+                    $this->flashMessenger()->addMessage($flashMessage, 'info');
+                    // If this is an email change, send a notification to the old
+                    // email address as well.
+                    if ($change) {
+                        $this->sendChangeNotificationEmail($user);
+                    }
                 } catch (MailException $e) {
                     $this->flashMessenger()->addMessage($e->getMessage(), 'error');
                 }
@@ -1691,6 +1743,11 @@ class MyResearchController extends AbstractBase
                 $user = $table->getByVerifyHash($hash);
                 // If the hash is valid, store validation in DB and forward to login
                 if (null != $user) {
+                    // Apply pending email address change, if applicable:
+                    if (!empty($user->pending_email)) {
+                        $user->updateEmail($user->pending_email, true);
+                        $user->pending_email = '';
+                    }
                     $user->saveEmailVerified();
                     $this->setUpAuthenticationFromRequest();
 
@@ -1796,6 +1853,79 @@ class MyResearchController extends AbstractBase
         return $this->redirect()->toRoute('myresearch-home');
     }
 
+    /**
+     * Handling submission of a new email for a user.
+     *
+     * @return view
+     */
+    public function changeEmailAction()
+    {
+        // Always check that we are logged in and function is enabled first:
+        if (!$this->getAuthManager()->isLoggedIn()) {
+            return $this->forceLogin();
+        }
+        if (!$this->getAuthManager()->supportsEmailChange()) {
+            $this->flashMessenger()->addMessage('change_email_disabled', 'error');
+            return $this->redirect()->toRoute('home');
+        }
+        $view = $this->createViewModel($this->params()->fromPost());
+        // Display email
+        $user = $this->getUser();
+        $view->email = $user->email;
+        // Identification
+        $view->useRecaptcha = $this->recaptcha()->active('changeEmail');
+        // Special case: form was submitted:
+        if ($this->formWasSubmitted('submit', $view->useRecaptcha)) {
+            // Do CSRF check
+            $csrf = $this->serviceLocator->get(\VuFind\Validator\Csrf::class);
+            if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
+                throw new \VuFind\Exception\BadRequest(
+                    'error_inconsistent_parameters'
+                );
+            }
+            // Update email
+            $validator = new \Zend\Validator\EmailAddress();
+            $email = $this->params()->fromPost('email', '');
+            try {
+                if (!$validator->isValid($email)) {
+                    throw new AuthException('Email address is invalid');
+                }
+                $this->getAuthManager()->updateEmail($user, $email);
+                // If we have a pending change, we need to send a verification email:
+                if (!empty($user->pending_email)) {
+                    $this->sendVerificationEmail($user, true);
+                } else {
+                    $this->flashMessenger()
+                        ->addMessage('new_email_success', 'success');
+                }
+            } catch (AuthException $e) {
+                $this->flashMessenger()->addMessage($e->getMessage(), 'error');
+                return $view;
+            }
+            // Return to account home
+            return $this->redirect()->toRoute('myresearch-home');
+        } elseif ($this->getConfig()->Authentication->verify_email ?? false) {
+            $this->flashMessenger()
+                ->addMessage('change_email_verification_reminder', 'info');
+        }
+        if (!empty($user->pending_email)) {
+            $url = $this->url()->fromRoute('myresearch-emailnotverified')
+                . '?reverify=true';
+            $this->flashMessenger()->addMessage(
+                [
+                    'html' => true,
+                    'msg' => 'email_change_pending_html',
+                    'tokens' => [
+                        '%%pending%%' => $user->pending_email,
+                        '%%url%%' => $url,
+                    ],
+                ],
+                'info'
+            );
+        }
+        return $view;
+    }
+
     /**
      * Handling submission of a new password for a user.
      *
diff --git a/module/VuFind/src/VuFind/Db/Row/User.php b/module/VuFind/src/VuFind/Db/Row/User.php
index a759f61b005fb8868d24aaa7deb88163c6ef47da..2c007ab088d0256fcba038e2f3ee02ce2045ea0a 100644
--- a/module/VuFind/src/VuFind/Db/Row/User.php
+++ b/module/VuFind/src/VuFind/Db/Row/User.php
@@ -680,6 +680,28 @@ class User extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterface,
         return $this->save();
     }
 
+    /**
+     * Update the user's email address, if appropriate. Note that this does NOT
+     * automatically save the row; it assumes a subsequent call will be made to
+     * the save() method.
+     *
+     * @param string $email        New email address
+     * @param bool   $userProvided Was this email provided by the user (true) or
+     * an automated lookup (false)?
+     *
+     * @return void
+     */
+    public function updateEmail($email, $userProvided = false)
+    {
+        // Only change the email if it is a non-empty value and was user provided
+        // (the user is always right) or the previous email was NOT user provided
+        // (a value may have changed in an upstream system).
+        if (!empty($email) && ($userProvided || !$this->user_provided_email)) {
+            $this->email = $email;
+            $this->user_provided_email = $userProvided ? 1 : 0;
+        }
+    }
+
     /**
      * Get the list of roles of this identity
      *
diff --git a/module/VuFind/src/VuFind/Db/Table/User.php b/module/VuFind/src/VuFind/Db/Table/User.php
index 17fb1a3c6114d7fd1d0ce5841c56c7ff47e06f97..b23ddcef64adc2753d1c155c65926d61022cda8f 100644
--- a/module/VuFind/src/VuFind/Db/Table/User.php
+++ b/module/VuFind/src/VuFind/Db/Table/User.php
@@ -90,6 +90,9 @@ class User extends Gateway
         $row = $this->createRow();
         $row->username = $username;
         $row->created = date('Y-m-d H:i:s');
+        // Failing to initialize this here can cause Zend\Db errors in
+        // the VuFind\Auth\Shibboleth and VuFind\Auth\ILS integration tests.
+        $row->user_provided_email = 0;
         return $row;
     }
 
diff --git a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/AccountActionsTest.php b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/AccountActionsTest.php
index 338b5ae1315099f5b6d6462d6ad6a7ecc722e43b..01fefbb74d81f29822a65e64e9b2851eb903a320 100644
--- a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/AccountActionsTest.php
+++ b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/AccountActionsTest.php
@@ -143,6 +143,79 @@ class AccountActionsTest extends \VuFindTest\Unit\MinkTestCase
         $this->snooze();
     }
 
+    /**
+     * Test that changing email is disabled by default.
+     *
+     * @return void
+     */
+    public function testChangeEmailDisabledByDefault()
+    {
+        // Go to profile page:
+        $session = $this->getMinkSession();
+        $session->visit($this->getVuFindUrl('/MyResearch/Profile'));
+        $page = $session->getPage();
+
+        // Log in
+        $this->clickCss($page, '#loginOptions a');
+        $this->fillInLoginForm($page, 'username1', 'good');
+        $this->clickCss($page, '.modal-body .btn.btn-primary');
+        $this->snooze();
+
+        // Now confirm that email button is absent:
+        $link = $page->findLink('Change Email Address');
+        $this->assertFalse(is_object($link));
+    }
+
+    /**
+     * Test changing an email.
+     *
+     * @return void
+     */
+    public function testChangeEmail()
+    {
+        // Turn on email change option:
+        $this->changeConfigs(
+            [
+                'config' => [
+                    'Authentication' => [
+                        'change_email' => true,
+                    ]
+                ]
+            ]
+        );
+
+        // Go to profile page:
+        $session = $this->getMinkSession();
+        $session->visit($this->getVuFindUrl('/MyResearch/Profile'));
+        $page = $session->getPage();
+
+        // Log in
+        $this->clickCss($page, '#loginOptions a');
+        $this->fillInLoginForm($page, 'username1', 'good');
+        $this->clickCss($page, '.modal-body .btn.btn-primary');
+        $this->snooze();
+
+        // Now click change email button:
+        $this->findAndAssertLink($page, 'Change Email Address')->click();
+        $this->snooze();
+
+        // Change the email:
+        $this->findCssAndSetValue($page, '[name="email"]', 'new@email.com');
+        $this->clickCss($page, '[name="submit"]');
+        $this->snooze();
+        $this->assertEquals(
+            'Your email address has been changed successfully',
+            $this->findCss($page, '.alert-success')->getText()
+        );
+
+        // Now go to profile page and confirm that email has changed:
+        $session->visit($this->getVuFindUrl('/MyResearch/Profile'));
+        $this->assertEquals(
+            'First Name: Tester Last Name: McTestenson Email: new@email.com',
+            $this->findCss($page, '.table-striped')->getText()
+        );
+    }
+
     /**
      * Standard teardown method.
      *
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php
index 167a364a76b01a964a1ac5339571ef324cae812c..2cfca678a4c3374f92bf60afe51307dabb7a801b 100644
--- a/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php
@@ -249,6 +249,24 @@ class ManagerTest extends \VuFindTest\Unit\TestCase
         $this->assertTrue($this->getManager($config, null, null, $pm)->supportsRecovery());
     }
 
+    /**
+     * Test supportsEmailChange
+     *
+     * @return void
+     */
+    public function testSupportsEmailChange()
+    {
+        // Most common case -- no:
+        $this->assertFalse($this->getManager()->supportsEmailChange());
+
+        // Less common case -- yes:
+        $pm = $this->getMockPluginManager();
+        $config = ['Authentication' => ['change_email' => true]];
+        $this->assertTrue($this->getManager($config, null, null, $pm)->supportsEmailChange());
+        $config = ['Authentication' => ['change_email' => false]];
+        $this->assertFalse($this->getManager($config, null, null, $pm)->supportsEmailChange());
+    }
+
     /**
      * Test supportsPasswordChange
      *
@@ -262,9 +280,11 @@ class ManagerTest extends \VuFindTest\Unit\TestCase
         // Less common case -- yes:
         $pm = $this->getMockPluginManager();
         $db = $pm->get('Database');
-        $db->expects($this->once())->method('supportsPasswordChange')->will($this->returnValue(true));
+        $db->expects($this->any())->method('supportsPasswordChange')->will($this->returnValue(true));
         $config = ['Authentication' => ['change_password' => true]];
         $this->assertTrue($this->getManager($config, null, null, $pm)->supportsPasswordChange());
+        $config = ['Authentication' => ['change_password' => false]];
+        $this->assertFalse($this->getManager($config, null, null, $pm)->supportsPasswordChange());
     }
 
     /**
diff --git a/themes/bootstrap3/templates/myresearch/changeemail.phtml b/themes/bootstrap3/templates/myresearch/changeemail.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..356699c90a298e0479f0002d7009d822ffa1074c
--- /dev/null
+++ b/themes/bootstrap3/templates/myresearch/changeemail.phtml
@@ -0,0 +1,32 @@
+<?php
+    // Set up page title:
+    $this->headTitle($this->translate('Change Email Address'));
+
+    // Set up breadcrumbs:
+    $this->layout()->breadcrumbs = '<li><a href="' . $this->url('myresearch-home') . '">' . $this->transEsc('Your Account') . '</a></li>'
+        . '<li class="active">' . $this->transEsc('Change Email Address') . '</li>';
+?>
+<div class="<?=$this->layoutClass('mainbody')?>">
+
+  <h2><?=$this->transEsc('Change Email Address') ?></h2>
+  <?=$this->flashmessages() ?>
+
+  <?php if (!$this->auth()->getManager()->supportsEmailChange($this->auth_method)): ?>
+    <div class="error"><?=$this->transEsc('change_email_disabled') ?></div>
+  <?php else: ?>
+    <form id="newemail" class="form-new-email" action="<?=$this->url('myresearch-changeemail') ?>" method="post" data-toggle="validator" role="form">
+      <input type="hidden" value="<?=$this->escapeHtmlAttr($this->auth()->getManager()->getCsrfHash())?>" name="csrf"/>
+      <div class="form-group">
+        <label class="control-label"><?=$this->transEsc('Email Address') ?>:</label>
+        <input type="text" name="email" class="form-control" value="<?=$this->escapeHtmlAttr($this->email)?>" />
+      </div>
+      <?=$this->recaptcha()->html($this->useRecaptcha) ?>
+      <div class="form-group">
+        <input class="btn btn-primary" name="submit" type="submit" value="<?=$this->transEsc('Submit')?>" />
+      </div>
+    </form>
+  <?php endif; ?>
+</div>
+<div class="<?=$this->layoutClass('sidebar')?>">
+  <?=$this->context($this)->renderInContext("myresearch/menu.phtml", ['active' => 'newpassword'])?>
+</div>
diff --git a/themes/bootstrap3/templates/myresearch/profile.phtml b/themes/bootstrap3/templates/myresearch/profile.phtml
index e38e97a926b1cb30bb63079772c3fa15995643b4..aed26de8389541fe7e40ea86121ce20ae06a9dbb 100644
--- a/themes/bootstrap3/templates/myresearch/profile.phtml
+++ b/themes/bootstrap3/templates/myresearch/profile.phtml
@@ -53,6 +53,12 @@
   </table>
 
   <div id="account-actions">
+    <?php if ($this->auth()->getManager()->supportsEmailChange()): ?>
+      <a class="btn btn-default" href="<?=$this->url('myresearch-changeemail') ?>">
+        <i class="fa fa-fw fa-envelope" aria-hidden="true"></i> <?=$this->transEsc('Change Email Address') ?>
+      </a>
+    <?php endif; ?>
+
     <?php if ($this->auth()->getManager()->supportsPasswordChange()): ?>
       <a class="btn btn-default" href="<?=$this->url('myresearch-changepassword') ?>">
         <i class="fa fa-fw fa-lock" aria-hidden="true"></i> <?=$this->transEsc('Change Password') ?>
diff --git a/themes/root/templates/Email/notify-email-change.phtml b/themes/root/templates/Email/notify-email-change.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..6ed60aaf2b49fe210e8793176a968d45a74c7290
--- /dev/null
+++ b/themes/root/templates/Email/notify-email-change.phtml
@@ -0,0 +1,4 @@
+<?=$this->translate(
+  'change_notification_email_message',
+  ['%%library%%' => $this->library, '%%url%%' => $this->url, '%%email%%' => $this->email])
+?>