Merge "Fix comments on patch set getting out to 'Auto Merge'"
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 973e97a..c89f0df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -245,7 +245,7 @@
     try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
           revision.getChange().getProject(), revision.getUser(), ts)) {
       Account.Id id = bu.getUser().getAccountId();
-      boolean ccOrReviewer = input.labels != null;
+      boolean ccOrReviewer = input.labels != null && !input.labels.isEmpty();
 
       if (!ccOrReviewer) {
         // Check if user was already CCed or reviewing prior to this review.
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 479f389..4c8c64f 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -31,6 +31,7 @@
     attached: function() {
       this.listen(document, 'server-error', '_handleServerError');
       this.listen(document, 'network-error', '_handleNetworkError');
+      this.listen(document, 'show-alert', '_handleShowAlert');
     },
 
     detached: function() {
@@ -61,6 +62,10 @@
       }
     },
 
+    _handleShowAlert: function(e) {
+      this._showAlert(e.detail.message);
+    },
+
     _handleNetworkError: function(e) {
       this._showAlert('Server unavailable');
       console.error(e.detail.error.message);
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 44cbde0..e2cacf9 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -134,5 +134,12 @@
         });
       });
     });
+
+    test('show alert', function() {
+      sandbox.stub(element, '_showAlert');
+      element.fire('show-alert', {message: 'foo'});
+      assert.isTrue(element._showAlert.calledOnce);
+      assert.isTrue(element._showAlert.lastCall.calledWithExactly('foo'));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 74344a7..3819bfe 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -64,7 +64,11 @@
         if (data.hash[0] !== '/') {
           data.hash = '/' + data.hash;
         }
-        page.redirect(data.hash);
+        var newUrl = data.hash;
+        if (newUrl.indexOf('/VE/') === 0) {
+          newUrl = '/settings' + data.hash;
+        }
+        page.redirect(newUrl);
         return;
       }
       restAPI.getLoggedIn().then(function(loggedIn) {
@@ -171,6 +175,19 @@
       app.params = params;
     });
 
+    page(/^\/settings\/VE\/(\S+)/, function(data) {
+      restAPI.getLoggedIn().then(function(loggedIn) {
+        if (loggedIn) {
+          app.params = {
+            view: 'gr-settings-view',
+            emailToken: data.params[0],
+          };
+        } else {
+          page.show('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
     page(/^\/settings\/?/, function(data) {
       restAPI.getLoggedIn().then(function(loggedIn) {
         if (loggedIn) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
index 0f00e18..ab6854f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -16,6 +16,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../gr-diff-comment/gr-diff-comment.html">
 
 <dom-module id="gr-diff-comment-thread">
@@ -45,6 +46,7 @@
       </template>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
   </template>
   <script src="gr-diff-comment-thread.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index 00719b2..8ef395f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -126,7 +126,19 @@
     },
 
     _createReplyComment: function(parent, content, opt_isEditing) {
-      var reply = this._newReply(parent.id, parent.line, content);
+      var reply = this._newReply(
+          this._orderedComments[this._orderedComments.length - 1].id,
+          parent.line,
+          content);
+
+      // If there is currently a comment in an editing state, add an attribute
+      // so that the gr-diff-comment knows not to populate the draft text.
+      for (var i = 0; i < this.comments.length; i++) {
+        if (this.comments[i].__editing) {
+          reply.__otherEditing = true;
+          break;
+        }
+      }
 
       if (opt_isEditing) {
         reply.__editing = true;
@@ -224,6 +236,21 @@
       if (this.comments.length == 0) {
         this.fire('thread-discard', {lastComment: comment});
       }
+
+      // Check to see if there are any other open comments getting edited and
+      // set the local storage value to its message value.
+      for (var i = 0; i < this.comments.length; i++) {
+        if (this.comments[i].__editing) {
+          var commentLocation = {
+            changeNum: this.changeNum,
+            patchNum: this.patchNum,
+            path: this.comments[i].path,
+            line: this.comments[i].line,
+          };
+          return this.$.storage.setDraftComment(commentLocation,
+              this.comments[i].message);
+        }
+      }
     },
 
     _handleCommentUpdate: function(e) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index 29e63aa..1189f32 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -288,6 +288,170 @@
       draftEl.fire('comment-discard', null, {bubbles: false});
     });
 
+    test('first editing comment does not add __otherEditing attribute',
+        function(done) {
+      var commentEl = element.$$('gr-diff-comment');
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+        __draft: true,
+      }];
+      flushAsynchronousOperations();
+
+      commentEl.addEventListener('create-reply-comment', function() {
+        var editing = element._orderedComments.filter(function(c) {
+          return c.__editing == true;
+        });
+        assert.equal(editing.length, 1);
+        assert.equal(!!editing[0].__otherEditing, false);
+        done();
+      });
+      commentEl.fire('create-reply-comment', {comment: commentEl.comment},
+          {bubbles: false});
+    });
+
+    test('two editing comments adds __otherEditing attribute', function(done) {
+      var commentEl = element.$$('gr-diff-comment');
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+        __editing: true,
+        __draft: true,
+      }];
+      flushAsynchronousOperations();
+
+      commentEl.addEventListener('create-reply-comment', function() {
+        var editing = element._orderedComments.filter(function(c) {
+          return c.__editing == true;
+        });
+        assert.equal(editing.length, 2);
+        assert.equal(editing[1].__otherEditing, true);
+        done();
+      });
+      commentEl.fire('create-reply-comment', {comment: commentEl.comment},
+          {bubbles: false});
+    });
+
+    test('When editing other comments, local storage set after discard',
+        function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        in_reply_to: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:31.843000000',
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        __draftID: '1',
+        in_reply_to: 'baf0414d_60047215',
+        line: 5,
+        message: 'yes',
+        updated: '2015-12-08 19:48:32.843000000',
+        __draft: true,
+        __editing: true,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        __draftID: '2',
+        in_reply_to: 'baf0414d_60047215',
+        line: 5,
+        message: 'no',
+        updated: '2015-12-08 19:48:33.843000000',
+        __draft: true,
+        __editing: true,
+      }];
+      var storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+      flushAsynchronousOperations();
+
+      var draftEl =
+          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
+      assert.ok(draftEl);
+      draftEl.addEventListener('comment-discard', function() {
+        assert.isTrue(storageStub.called);
+        storageStub.restore();
+        done();
+      });
+      draftEl.fire('comment-discard', null, {bubbles: false});
+    });
+
+    test('When not editing other comments, local storage not set after discard',
+        function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:31.843000000',
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        __draftID: '1',
+        in_reply_to: 'baf0414d_60047215',
+        line: 5,
+        message: 'yes',
+        updated: '2015-12-08 19:48:32.843000000',
+        __draft: true,
+        __editing: true,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        __draftID: '2',
+        in_reply_to: 'baf0414d_60047215',
+        line: 5,
+        message: 'no',
+        updated: '2015-12-08 19:48:33.843000000',
+        __draft: true,
+      }];
+      var storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+      flushAsynchronousOperations();
+
+      var draftEl =
+          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
+      assert.ok(draftEl);
+      draftEl.addEventListener('comment-discard', function() {
+        assert.isFalse(storageStub.called);
+        storageStub.restore();
+        done();
+      });
+      draftEl.fire('comment-discard', null, {bubbles: false});
+    });
+
     test('comment-update', function() {
       var commentEl = element.$$('gr-diff-comment');
       var updatedComment = {
@@ -355,5 +519,35 @@
       assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
       expandCollapseStub.restore();
     });
+
+    test('comment in_reply_to is either null or most recent comment id',
+        function() {
+      var comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+        }, {
+          id: 'sallys_confession',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }];
+      element.set('comments', comments);
+      element._createReplyComment(comments[3], 'dummy', true);
+      flushAsynchronousOperations();
+      assert.equal(element._orderedComments.length, 5);
+      assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index d974fe5..b0311dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -281,6 +281,9 @@
     _messageTextChanged: function(newValue, oldValue) {
       if (!this.comment || (this.comment && this.comment.id)) { return; }
 
+      // Keep comment.message in sync so that gr-diff-comment-thread is aware
+      // of the current message in the case that another comment is deleted.
+      this.comment.message = this._messageText || '';
       this.debounce('store', function() {
         var message = this._messageText;
 
@@ -356,7 +359,8 @@
 
     _handleCancel: function(e) {
       e.preventDefault();
-      if (this.comment.message == null || this.comment.message.length == 0) {
+      if (this.comment.message === null ||
+          this.comment.message.trim().length === 0) {
         this._fireDiscard();
         return;
       }
@@ -408,7 +412,11 @@
     _loadLocalDraft: function(changeNum, patchNum, comment) {
       // Only apply local drafts to comments that haven't been saved
       // remotely, and haven't been given a default message already.
-      if (!comment || comment.id || comment.message) {
+      //
+      // Don't get local draft if there is another comment that is currently
+      // in an editing state.
+      if (!comment || comment.id || comment.message || comment.__otherEditing) {
+        delete comment.__otherEditing;
         return;
       }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index 6793144..59e5874 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -147,6 +147,49 @@
       showStub.restore();
     });
 
+    test('message is not retrieved from storage when other editing is true',
+        function(done) {
+      var storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      var loadSpy = sandbox.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+        __otherEditing: true,
+      };
+      flush(function() {
+        assert.isTrue(loadSpy.called);
+        assert.isFalse(storageStub.called);
+        done();
+      });
+    });
+
+    test('message is retrieved from storage when there is no other editing',
+        function(done) {
+      var storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      var loadSpy = sandbox.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+      };
+      flush(function() {
+        assert.isTrue(loadSpy.called);
+        assert.isTrue(storageStub.called);
+        done();
+      });
+    });
+
     test('comment expand and collapse', function() {
       element.collapsed = true;
       assert.isFalse(isVisible(element.$$('gr-formatted-text')),
@@ -436,6 +479,7 @@
             __editing: true,
             line: 5,
             path: '/path/to/file',
+            message: 'good news, everyone!',
           },
           patchNum: 1,
         },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index e57940a..3e1b415 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -51,6 +51,9 @@
       .downloadLink:not([href]) {
         color: #999;
       }
+      .navLinks {
+        white-space: nowrap;
+      }
       .reviewed {
         display: inline-block;
         margin: 0 .25em;
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 83ceb57..269240a 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -117,6 +117,7 @@
       </template>
       <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
         <gr-settings-view
+            params="[[params]]"
             on-account-detail-update="_handleAccountDetailUpdate">
         </gr-settings-view>
       </template>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 5e20c9e..e12d434 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -32,11 +32,21 @@
      * @event title-change
      */
 
+    /**
+     * Fired with email confirmation text.
+     *
+     * @event show-alert
+     */
+
     properties: {
       prefs: {
         type: Object,
         value: function() { return {}; },
       },
+      params: {
+        type: Object,
+        value: function() { return {}; },
+      },
       _accountInfoMutable: Boolean,
       _accountInfoChanged: Boolean,
       _diffPrefs: Object,
@@ -116,7 +126,6 @@
       var promises = [
         this.$.accountInfo.loadData(),
         this.$.watchedProjectsEditor.loadData(),
-        this.$.emailEditor.loadData(),
         this.$.groupList.loadData(),
         this.$.httpPass.loadData(),
       ];
@@ -139,6 +148,18 @@
         }
       }.bind(this)));
 
+      if (this.params.emailToken) {
+        promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
+          function(message) {
+            if (message) {
+              this.fire('show-alert', {message: message});
+            }
+            this.$.emailEditor.loadData();
+          }.bind(this)));
+      } else {
+        promises.push(this.$.emailEditor.loadData());
+      }
+
       this._loadingPromise = Promise.all(promises).then(function() {
         this._loading = false;
       }.bind(this));
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index 0b42da5..6a2af18 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -43,6 +43,7 @@
     var preferences;
     var diffPreferences;
     var config;
+    var sandbox;
 
     function valueOf(title, fieldsetid) {
       var sections = element.$[fieldsetid].querySelectorAll('section');
@@ -65,11 +66,12 @@
     }
 
     function stubAddAccountEmail(statusCode) {
-      return sinon.stub(element.$.restAPI, 'addAccountEmail',
+      return sandbox.stub(element.$.restAPI, 'addAccountEmail',
           function() { return Promise.resolve({status: statusCode}); });
     }
 
     setup(function(done) {
+      sandbox = sinon.sandbox.create();
       account = {
         _account_id: 123,
         name: 'user name',
@@ -129,8 +131,12 @@
       element._loadingPromise.then(done);
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('calls the title-change event', function() {
-      var titleChangedStub = sinon.stub();
+      var titleChangedStub = sandbox.stub();
 
       // Create a new view.
       var newElement = document.createElement('gr-settings-view');
@@ -341,5 +347,52 @@
         done();
       });
     });
+
+    test('emails are loaded without emailToken', function() {
+      sandbox.stub(element.$.emailEditor, 'loadData');
+      element.params = {};
+      element.attached();
+      assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+    });
+
+    suite('when email verification token is provided', function() {
+      var resolveConfirm;
+
+      setup(function() {
+        sandbox.stub(element.$.emailEditor, 'loadData');
+        sandbox.stub(element.$.restAPI, 'confirmEmail', function() {
+          return new Promise(function(resolve) { resolveConfirm = resolve; });
+        });
+        element.params = {emailToken: 'foo'};
+        element.attached();
+      });
+
+      test('it is used to confirm email via rest API', function() {
+        assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
+        assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
+      });
+
+      test('emails are not loaded initially', function() {
+        assert.isFalse(element.$.emailEditor.loadData.called);
+      });
+
+      test('user emails are loaded after email confirmed', function(done) {
+        element._loadingPromise.then(function() {
+          assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+          done();
+        });
+        resolveConfirm();
+      });
+
+      test('show-alert is fired when email is confirmed', function(done) {
+        sandbox.spy(element, 'fire');
+        element._loadingPromise.then(function() {
+          assert.isTrue(element.fire.calledWith('show-alert', {message: 'bar'}));
+          done();
+        });
+        resolveConfirm('bar');
+      });
+
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index c8dfea3..3bcb50e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -80,8 +80,8 @@
       :host([loading][disabled]) {
         cursor: wait;
       }
-      :host(:focus:not([primary]:not[secondary])),
-      :host(:hover:not([primary]:not[secondary])) {
+      :host(:focus:not([primary]):not([secondary])),
+      :host(:hover:not([primary]):not([secondary])) {
         background-color: #f8f8f8;
         border-color: #aaa;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 3769a0b..4b52503 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -942,5 +942,15 @@
           this.getChangeActionURL(changeNum, patchNum, '/description'),
           {description: desc});
     },
+
+    confirmEmail: function(token) {
+      return this.send('PUT', '/config/server/email.confirm', {token: token})
+          .then(function(response) {
+            if (response.status === 204) {
+              return 'Email confirmed successfully.';
+            }
+            return null;
+          });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index a9cea24..f43d62c 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -333,10 +333,10 @@
 
     test('saveDiffPreferences invalidates cache line', function() {
       var cacheKey = '/accounts/self/preferences.diff';
-      var sendStub = sandbox.stub(element, 'send');
+      sandbox.stub(element, 'send');
       element._cache[cacheKey] = {tab_size: 4};
       element.saveDiffPreferences({tab_size: 8});
-      assert.isTrue(sendStub.called);
+      assert.isTrue(element.send.called);
       assert.notOk(element._cache[cacheKey]);
     });
 
@@ -414,10 +414,17 @@
     });
 
     test('savPreferences normalizes download scheme', function() {
-      var sendStub = sandbox.stub(element, 'send');
+      sandbox.stub(element, 'send');
       element.savePreferences({download_scheme: 'HTTP'});
-      assert.isTrue(sendStub.called);
-      assert.equal(sendStub.lastCall.args[2].download_scheme, 'http');
+      assert.isTrue(element.send.called);
+      assert.equal(element.send.lastCall.args[2].download_scheme, 'http');
+    });
+
+    test('confirmEmail', function() {
+      sandbox.spy(element, 'send');
+      element.confirmEmail('foo');
+      assert.isTrue(element.send.calledWith(
+          'PUT', '/config/server/email.confirm', {token: 'foo'}));
     });
   });
 </script>