83dd847b7449dd0fa83c22a4d60854f8d20d2847
max
  Fri Sep 18 08:51:14 2026 -0700
hgLogin: stop telling a CILogon user that CILogon shares no email address

#Preview2 week - bugs introduced now will need a build patch to fix
QA found three messages on the social sign-in pages claiming the provider does
not give us an address.  That was written when ORCID, which really releases
none, was the only provider reaching those pages.  CILogon does release the
institution's address; we drop it because CILogon never marks it verified and
login.oauth.cilogon.trustEmail is what says we may take it anyway.  Once the
address is dropped, nothing downstream could tell "released nothing" from
"released something we would not take".

oauthFetchIdentity now keeps the dropped address in a separate field that
matching and sign-in never look at, and the three messages read it: the choose
a username page, the confirmation page, and the one that turns down a typed
address that already belongs to an account.  The address box on the choose a
username page starts from it as well, since retyping what we were just handed
is the last thing a person wants to do.  It still has to clear the duplicate
check and be confirmed by mail, exactly as a typed address does.

Three more things on the confirmation page, all from the same QA pass:

The generic "a confirmation email has been sent" paragraph printed below the
"use this address instead" form, so it read as if a mail had already gone to
whatever was about to be typed in.  It now goes above the form.

Correcting the address redisplayed the same page with the new address swapped
in and nothing to say the change had worked.  It now says so.  The rejection
reason for an address that is already in use was set but never printed, so a
refused change looked like nothing happening at all; that is printed now too.

Reworded the unconfirmed-account explanation as QA suggested.

refs #38339

diff --git src/hg/hgLogin/hgLogin.c src/hg/hgLogin/hgLogin.c
index 0fdc857f89f..3811c7338f9 100644
--- src/hg/hgLogin/hgLogin.c
+++ src/hg/hgLogin/hgLogin.c
@@ -402,79 +402,109 @@
 {
 char *returnURL = getReturnToUrlForAttr();
 hPrintf(
     "<div id=\"confirmationBox\" class=\"centeredContainer formBox\">"
     "\n"
     "<h2>%s</h2>", brwName);
 /* Arriving here straight after a social sign-in is confusing on its own: the user asked to sign
  * in, not to fill in a form, and gets told to go and read their mail.  Say first what happened
  * and which address it turns on.  These are set only by the two flows that send a user here
  * from a provider sign-in; a plain email signup sets none of them and the page reads as before.
  * Everything below is either config or an address, both of which can reach the cart from a
  * request, so encode all of it. */
 char *provider = cartUsualString(cart, "hgLogin_actMailProvider", "");
 char *address = cartUsualString(cart, "hgLogin_actMailTo", "");
 char *existingUser = cartUsualString(cart, "hgLogin_actMailUser", "");
-if (isNotEmpty(provider) && isNotEmpty(address))
-    {
+char *unverified = cartUsualString(cart, "hgLogin_actMailUnverified", "");
+boolean justChanged = isNotEmpty(cartUsualString(cart, "hgLogin_actMailChanged", ""));
+boolean fromProvider = isNotEmpty(provider) && isNotEmpty(address);
+/* Decide up front whether the "use a different address" form is coming, so the generic "we
+ * have sent you a mail" paragraph can be printed above it.  Below the form it read as if a
+ * confirmation had already gone to whatever was about to be typed into it.  The form is only
+ * for an existing account whose address was never confirmed, and only for the person who just
+ * came back from the provider -- pendingIdentityValid() is that check. */
+boolean offerNewAddress = fromProvider && isNotEmpty(existingUser) && pendingIdentityValid();
 char *encProvider = htmlEncode(provider);
 char *encAddress = htmlEncode(address);
+if (fromProvider)
+    {
     if (isEmpty(existingUser))
+        {
+        /* An address the provider released and we would not take is not the same as no address
+         * at all, and CILogon is the first case of it (#38339). */
+        if (isNotEmpty(unverified))
+            hPrintf("<p>You signed in with %s, and %s does not tell us whether the email "
+                "address it gave us really belongs to you, so we asked you for one. No %s "
+                "account uses <b>%s</b> yet, so we are making a new account for it. Confirming "
+                "the address is the last step.</p>",
+                encProvider, encProvider, brwName, encAddress);
+        else
             hPrintf("<p>You signed in with %s, and %s did not tell us an email address, so we "
                 "asked you for one. No %s account uses <b>%s</b> yet, so we are making a new "
                 "account for it. Confirming the address is the last step.</p>",
                 encProvider, encProvider, brwName, encAddress);
+        }
     else
         {
         char *encUser = htmlEncode(existingUser);
-        hPrintf("<p>Your %s sign-in belongs to the %s account <b>%s</b>, but the address on "
-            "that account, <b>%s</b>, has never been confirmed. Confirm it once and %s will "
-            "sign you straight in from then on.</p>",
+        /* Say that the address was changed.  Without this the page comes back looking exactly
+         * as it did before the change, the new address being the only sign anything happened. */
+        if (justChanged)
+            hPrintf("<p>The email address on the %s account <b>%s</b> is now <b>%s</b>.</p>",
+                brwName, encUser, encAddress);
+        else
+            hPrintf("<p>Your %s sign-in is linked to the %s account <b>%s</b>, but the email "
+                "address on that account, <b>%s</b>, has not been confirmed. Once it is "
+                "confirmed, %s will sign you in directly.</p>",
                 encProvider, brwName, encUser, encAddress, encProvider);
-        /* If that address is wrong the confirmation can never arrive, and this account has no
-         * other way in, so offer to replace it here.  pendingIdentityValid() is the check that
-         * this really is the person who just came back from the provider. */
-        if (pendingIdentityValid())
+        freeMem(encUser);
+        }
+    }
+/* A rejected address (changePendingEmail) leaves its reason here, and this page is where the
+ * user sees it -- nothing else prints it on the way through. */
+if (isNotEmpty(errMsg))
+    hPrintf("<p><span style='color:red;'>%s</span></p>", errMsg);
+hPrintf(
+    "<p id=\"confirmationMsg\" class=\"confirmationTxt\">A confirmation email has been sent to you. \n"
+    "Please click the confirmation link in the email to activate your account.</p>"
+    "<p>You may have to look in your spam folder for an email from genome-www@soe.ucsc.edu, "
+    "especially if you use Microsoft Outlook or Hotmail.</p>");
+if (offerNewAddress)
     {
-            hPrintf("<p>If <b>%s</b> is not an address you can read, enter the right one and we "
-                "will send the confirmation there instead.</p>", encAddress);
+    /* If that address is wrong the confirmation can never arrive, and this account has no other
+     * way in, so offer to replace it here. */
+    hPrintf("<p>If <b>%s</b> is not the right email address, enter the right one, and we will "
+        "send the confirmation there instead.</p>", encAddress);
     hPrintf("<form method=\"post\" action=\"%s\" name=\"fixEmailForm\">", hgLoginUrl);
     hPrintf("<div class=\"inputGroup\">"
         "<label for=\"fixEmailAddr\">Email address</label>"
         "<input type=\"text\" name=\"hgLogin_email\" value=\"\" size=\"30\" "
         "id=\"fixEmailAddr\"></div>");
     hPrintf("<div class=\"formControls\">"
         "<input type=\"submit\" name=\"hgLogin.do.changePendingEmail\" "
         "value=\"Use this address instead\" class=\"largeButton\"></div></form>");
     }
-        freeMem(encUser);
-        }
+hPrintf("\n<p><a href=\"%s\">Return</a></p>", returnURL);
 freeMem(encProvider);
 freeMem(encAddress);
-    }
-hPrintf(
-    "<p id=\"confirmationMsg\" class=\"confirmationTxt\">A confirmation email has been sent to you. \n"
-    "Please click the confirmation link in the email to activate your account.</p>"
-    "<p>You may have to look in your spam folder for an email from genome-www@soe.ucsc.edu, "
-    "especially if you use Microsoft Outlook or Hotmail.</p>"
-    "\n"
-    "<p><a href=\"%s\">Return</a></p>", returnURL);
 cartRemove(cart, "hgLogin_email");
 cartRemove(cart, "hgLogin_userName");
 cartRemove(cart, "hgLogin_actMailProvider");
 cartRemove(cart, "hgLogin_actMailTo");
 cartRemove(cart, "hgLogin_actMailUser");
+cartRemove(cart, "hgLogin_actMailUnverified");
+cartRemove(cart, "hgLogin_actMailChanged");
 }
 
 void sendActMailOut(char *email, char *subject, char *msg)
 /* send mail to email address */
 {
 int result;
 
 result = mailViaPipeBounce(email, subject, msg, returnAddr);
 
 if (result == -1)
     {
     hPrintf(
         "<h2>%s</h2>", brwName);
     hPrintf(
         "<p align=\"left\">"
@@ -1951,30 +1981,32 @@
 if (confirmRecov)
     sendRecovEmailConfirmMail(recovEmail, user, recovEmail, "N");
 /* send out activate code mail, and display the mail confirmation box */
 cartRemove(cart, "hgLogin_email");
 cartRemove(cart, "hgLogin_email2");
 cartRemove(cart, "hgLogin_userName");
 cartRemove(cart, "user");
 cartRemove(cart, "token");
 /* This page is shared with the social-login flows, which leave it a note saying which provider
  * and address to explain.  Those are cleared when that page renders, but the hand-off is a
  * JavaScript redirect and a user who never lands on it keeps them in the cart.  Drop them here
  * so a plain signup can never inherit somebody else's explanation. */
 cartRemove(cart, "hgLogin_actMailProvider");
 cartRemove(cart, "hgLogin_actMailTo");
 cartRemove(cart, "hgLogin_actMailUser");
+cartRemove(cart, "hgLogin_actMailUnverified");
+cartRemove(cart, "hgLogin_actMailChanged");
 redirectToLoginPage("hgLogin.do.displayActMailSuccess=1");
 }
 
 void accountHelp(struct sqlConnection *conn)
 /* email user username(s) or new password */
 {
 char query[1024];   // room for an address-matching clause holding a long address twice
 char *email = cartUsualString(cart, "hgLogin_email", "");
 char *username = cartUsualString(cart, "hgLogin_userName", "");
 char *helpWith = cartUsualString(cart, "hgLogin_helpWith", "");
 
 /* Passwordless email login link */
 if (sameString(helpWith,"loginLink"))
     {
     sendEmailLink(conn);
@@ -2367,46 +2399,51 @@
 boolean ok = sameString(sig, expected);
 freeMem(expected);
 return ok;
 }
 
 static void setPendingIdentity(struct oauthIdentity *id)
 /* Stash an authenticated-but-not-yet-linked identity in the cart so it survives a form
  * round-trip (the "choose a username" or "choose an account" page).  The signature is what
  * proves, on the way back, that we really verified this identity, for this browser, recently. */
 {
 char timeStr[32];
 safef(timeStr, sizeof(timeStr), "%ld", clock1());
 cartSetString(cart, "oauth_pending_provider", id->provider);
 cartSetString(cart, "oauth_pending_subject", id->subject);
 cartSetString(cart, "oauth_pending_email", emptyForNull(id->email));
+/* Not signed, the same as oauth_pending_name: it decides nothing, it only picks the wording of
+ * the page that asks for an address.  dropRequestSuppliedFlowVars keeps a request-supplied copy
+ * from standing in for ours. */
+cartSetString(cart, "oauth_pending_email_unverified", emptyForNull(id->emailUnverified));
 char *emailVerified = id->emailVerified ? "1" : "0";
 cartSetString(cart, "oauth_pending_email_verified", emailVerified);
 cartSetString(cart, "oauth_pending_name", emptyForNull(id->displayName));
 cartSetString(cart, "oauth_pending_time", timeStr);
 cartSetString(cart, "oauth_pending_sig",
     oauthPendingSig(id->provider, id->subject, emptyForNull(id->email), emailVerified, timeStr));
 }
 
 static void clearPendingIdentity()
 /* Remove the pending-identity cart variables.  Call this on every path that finishes with the
  * pending identity -- success or definitive failure -- so a stale signature is not left behind
  * in the cart to be swept into a saved session. */
 {
 cartRemove(cart, "oauth_pending_provider");
 cartRemove(cart, "oauth_pending_subject");
 cartRemove(cart, "oauth_pending_email");
+cartRemove(cart, "oauth_pending_email_unverified");
 cartRemove(cart, "oauth_pending_email_verified");
 cartRemove(cart, "oauth_pending_name");
 cartRemove(cart, "oauth_pending_time");
 cartRemove(cart, "oauth_pending_sig");
 }
 
 static void linkIdentity(struct sqlConnection *conn, uint idx, struct oauthIdentity *id)
 /* Insert or refresh the gbMemberIdentity row linking idx to this provider identity. */
 {
 char query[1024];
 char *email = emptyForNull(id->email);
 sqlSafef(query, sizeof(query),
     "INSERT INTO gbMemberIdentity SET idx=%u, provider='%s', subject='%s', email='%s', "
     "created=NOW(), lastUse=NOW() "
     "ON DUPLICATE KEY UPDATE idx=%u, email='%s', lastUse=NOW()",
@@ -2429,30 +2466,44 @@
 static char *oauthProviderEmail()
 /* The address the provider released for the pending identity, or NULL if it released none or
  * released something that is not a usable address.
  * When this is non-NULL the "choose a username" page must not ask for an address at all.  We
  * already have one, from a source the person cannot type into, so a text box would only invite
  * an edit -- and a box we then accept unchanged, without ever writing to it, is the worst of
  * both worlds: it looks like a question we check the answer to, and it is not.  Either we have
  * an address and use it, or we do not have one and must confirm what the user types. */
 {
 char *email = cartUsualString(cart, "oauth_pending_email", "");
 if (isEmpty(email) || spc_email_isvalid(email) == 0)
     return NULL;
 return email;
 }
 
+static char *oauthUnverifiedEmail()
+/* The address the provider released for the pending identity and we would not take, because it
+ * did not say the address is verified and hg.conf does not trust the provider (see
+ * oauthFetchIdentity).  NULL when the provider released nothing at all.
+ * This is the difference between "we were told nothing" and "we were told something we cannot
+ * act on", which the user needs to hear and which the form can start from.  Never match on it:
+ * whatever the user does with it, it still has to be confirmed by mail. */
+{
+char *email = cartUsualString(cart, "oauth_pending_email_unverified", "");
+if (isEmpty(email))
+    return NULL;
+return email;
+}
+
 void completeAccountPage(struct sqlConnection *conn)
 /* Ask a first-time social-login user to confirm a username (and email) for a new account. */
 {
 char *provider = cartUsualString(cart, "oauth_pending_provider", "");
 char *email = cartUsualString(cart, "oauth_pending_email", "");
 char *name = cartUsualString(cart, "oauth_pending_name", "");
 if (isEmpty(provider) || !pendingIdentityValid())
     {
     clearPendingIdentity();
     displayLoginPage(conn);
     return;
     }
 char *providerEmail = oauthProviderEmail();
 char *suggested = cartUsualString(cart, "hgLogin_userName", "");
 if (isEmpty(suggested))
@@ -2462,53 +2513,73 @@
 
 hPrintf("<div id=\"completeAccountBox\" class=\"centeredContainer formBox\">"
     "<h2>%s</h2>", brwName);
 hPrintf("<h3>Choose a username</h3>");
 hPrintf("<p>You signed in with %s. Pick a username for your new %s account. "
     "You can change the suggested name below.</p>", label, brwName);
 /* Explain why this is always a new account when the provider released no address (ORCID does
  * this by design: its OpenID Connect offers only the "openid" scope, so the ORCID iD is all we
  * ever get).  Without an address we cannot tell a returning user from a new one, so every first
  * sign-in lands here, which surprised real users (#38341).  Keyed on whether an address arrived,
  * not on the provider's name: a mirror can call a provider anything it likes in hg.conf, so a
  * name test would silently miss it (#38213).
  * Test whether anything arrived, not whether oauthProviderEmail() accepted it.  A provider that
  * sends an address we cannot use -- spc_email_isvalid rejects every byte >= 127, so any
  * non-ASCII address -- also leaves us asking for one, but telling that user the provider shares
- * no address would be simply untrue. */
-if (isEmpty(email))
+ * no address would be simply untrue.
+ * Same for a provider that did release an address which we then dropped because it would not
+ * say the address is verified: CILogon does exactly this, and "does not share your email
+ * address with us" was plainly wrong for it (#38339). */
+char *unverified = oauthUnverifiedEmail();
+if (isEmpty(email) && unverified != NULL)
+    {
+    char *encUnverified = htmlEncode(unverified);
+    hPrintf("<p>%s gave us the email address <b>%s</b>, but does not tell us whether that "
+        "address really belongs to you, so we cannot use it to sign you in to an account that "
+        "already has it. Enter an address below and confirm it once; after that this sign-in "
+        "will work on its own. If you already have an account, use another sign-in option "
+        "instead.</p>", label, encUnverified);
+    freeMem(encUnverified);
+    }
+else if (isEmpty(email))
     hPrintf("<p>A new %s account is created for any %s sign-in we have not seen before, because "
         "%s does not share your email address with us. So you cannot sign in to an existing "
         "account this way. Use another sign-in option if you do not want to create a new "
         "account.</p>", brwName, label, label);
 else if (providerEmail == NULL)
     hPrintf("<p>We cannot use the email address %s gave us, so please enter one below.</p>",
         label);
 printUsernameNote();
 hPrintf("<span style='color:red;'>%s</span>", errMsg ? errMsg : "");
 hPrintf("<form method=\"post\" action=\"%s\" name=\"completeAccountForm\">", hgLoginUrl);
 hPrintf("<div class=\"inputGroup\">"
     "<label for=\"userName\">Username</label>"
     "<input type=\"text\" name=\"hgLogin_userName\" value=\"%s\" size=\"30\" id=\"userName\">"
     "</div>", encSuggested);
 if (providerEmail == NULL)
     {
     /* No address from the provider, so we have to ask -- and because anyone can type anything
      * here, the account is not usable until the mailed link is opened.  Say that next to the box
      * rather than springing the confirmation page on the user after they submit.  Show back what
-     * they typed so an error does not wipe the address they are being asked to correct. */
-    char *encTyped = htmlEncode(cartUsualString(cart, "hgLogin_email", ""));
+     * they typed so an error does not wipe the address they are being asked to correct.
+     * Nothing typed yet and the provider did release an address we could not take?  Start from
+     * that one.  It is almost always the address the person wants, and it still has to survive
+     * the duplicate check and the confirmation mail, so offering it grants nothing. */
+    char *typed = cartUsualString(cart, "hgLogin_email", "");
+    if (isEmpty(typed) && (unverified != NULL) && (spc_email_isvalid(unverified) != 0))
+        typed = unverified;
+    char *encTyped = htmlEncode(typed);
     hPrintf("<div class=\"inputGroup\">"
         "<label for=\"emailAddr\">Email address</label>"
         "<input type=\"text\" name=\"hgLogin_email\" value=\"%s\" size=\"30\" id=\"emailAddr\">"
         "</div>", encTyped);
     freeMem(encTyped);
     if (!sameWord(returnAddr, "NOEMAIL"))
         hPrintf("<p style=\"font-size:0.9em\">We will email a confirmation link to this address. "
             "Open the link to finish creating your account.</p>");
     }
 else
     {
     /* We already have an address from the provider, so do not ask for one.  A box here would be
      * a question we do not check the answer to. */
     char *encProviderEmail = htmlEncode(providerEmail);
     /* Only promise the change-email page where it exists: it, and the confirmation link that
@@ -2596,36 +2667,49 @@
  * provider released is not affected: resolveIdentity has already matched it against existing
  * accounts and would not have sent us here.
  * Only activated accounts count, the same rule resolveIdentity and chooseAccount apply: an
  * unactivated row holds an address nobody ever proved they own, so letting one block a signup
  * would let anyone reserve a stranger's address. */
 if (providerEmail == NULL)
     {
     char query[1024];
     char *addrMatch = sqlAddressMatch(email);
     sqlSafef(query, sizeof(query),
         "SELECT count(*) FROM gbMembers WHERE %-s AND accountActivated='Y'", addrMatch);
     freeMem(addrMatch);
     if (sqlQuickNum(conn, query) > 0)
         {
         char buf[1024];
+        /* The provider may well have handed us this very address and we dropped it for want of
+         * a verified flag (CILogon, #38339).  Saying it never gave us the address would be
+         * wrong there, and the way out is a different one: what is missing is the provider's
+         * word that the address is the user's, not the address itself. */
+        char *unverified = oauthUnverifiedEmail();
+        if ((unverified != NULL) && sameWord(unverified, email))
+            safef(buf, sizeof(buf),
+                "An account with this email address already exists. %s gave us that address but "
+                "does not tell us that it is yours, so we cannot sign you in to that account. To "
+                "reach it, sign in with a provider that does confirm your email address, or with "
+                "your username and password. To create a new account instead, enter a different "
+                "email address.", oauthProviderLabel(provider));
+        else
             safef(buf, sizeof(buf),
-            "An account with this email address already exists. %s did not give us that address, "
-            "so we cannot tell that it is yours and cannot sign you in to that account. To reach "
-            "it, sign in with a provider that does give us your email address, or with your "
-            "username and password. To create a new account instead, enter a different email "
-            "address.", oauthProviderLabel(provider));
+                "An account with this email address already exists. %s did not give us that "
+                "address, so we cannot tell that it is yours and cannot sign you in to that "
+                "account. To reach it, sign in with a provider that does give us your email "
+                "address, or with your username and password. To create a new account instead, "
+                "enter a different email address.", oauthProviderLabel(provider));
         freez(&errMsg);
         errMsg = cloneString(buf);
         completeAccountPage(conn);
         return;
         }
     }
 
 char *name = cartUsualString(cart, "oauth_pending_name", "");
 char *realName = isNotEmpty(name) ? name : user;
 
 /* The new account is created "activated" -- its address trusted for future auto-linking (see
  * resolveIdentity) -- when the address came from the provider.  An address the user typed gets
  * an inactive account and the usual confirmation mail, so a typed address can never be planted
  * as a trusted one.  An install that sends no mail has no way to confirm anything, so there it
  * is activated on the spot, the same compromise signup() makes. */
@@ -2639,47 +2723,53 @@
 sqlUpdate(conn, dyStringContents(q));
 dyStringFree(&q);
 uint idx = sqlLastAutoId(conn);
 
 struct oauthIdentity pending;
 ZeroVar(&pending);
 pending.provider = provider;
 pending.subject = subject;
 pending.email = email;
 linkIdentity(conn, idx, &pending);
 
 /* clearPendingIdentity frees the cart's copy of oauth_pending_provider, and provider points
  * straight at it (cartUsualString hands back the cart's own string, not a duplicate), so take
  * the label while it is still there. */
 char *providerLabel = cloneString(oauthProviderLabel(provider));
+/* Same reason: the confirmation page has to know whether the provider released an address we
+ * would not take, and clearPendingIdentity is about to drop that too. */
+char *unverifiedLabel = cloneString(emptyForNull(oauthUnverifiedEmail()));
 clearPendingIdentity();
 if (activateNow)
     {
     freeMem(providerLabel);
+    freeMem(unverifiedLabel);
     loginAndReturn(conn, user, idx);
     return;
     }
 /* Unconfirmed address: send the confirmation mail and say so, rather than signing the user in
  * and leaving a mail nobody has any reason to open.  Activating is what makes the address usable
  * for signing in by email link and for linking a later social login, so it is worth a click. */
 setupNewAccount(conn, email, user);
 /* Tell the confirmation page what to explain.  No user name here: this is a brand new account,
  * which is the one thing that page cannot work out for itself. */
 cartSetString(cart, "hgLogin_actMailProvider", providerLabel);
 cartSetString(cart, "hgLogin_actMailTo", email);
+cartSetString(cart, "hgLogin_actMailUnverified", unverifiedLabel);
 cartRemove(cart, "hgLogin_actMailUser");
 freeMem(providerLabel);
+freeMem(unverifiedLabel);
 cartRemove(cart, "hgLogin_email");
 cartRemove(cart, "hgLogin_userName");
 redirectToLoginPage("hgLogin.do.displayActMailSuccess=1");
 }
 
 void chooseAccountPage(struct sqlConnection *conn)
 /* Ask the user which of several accounts sharing an email address to sign in to.  Used by
  * two flows: OAuth (oauth_pending_* in the cart -> the chosen account is linked to the social
  * identity) and the passwordless email link (emailLogin_* in the cart -> just sign in). */
 {
 char *provider = cartUsualString(cart, "oauth_pending_provider", "");
 boolean emailMode = isEmpty(provider);
 if (emailMode && !emailLinkEnabled())
     {
     // The email-link chooser must not run where passwordless login is switched off.
@@ -2913,30 +3003,34 @@
                 "emailToken='', emailTokenExpires='' WHERE idx=%u", linked->idx);
             sqlUpdate(conn, query);
             }
         else if (!sameWord(returnAddr, "NOEMAIL") && isNotEmpty(linked->email))
             {
             /* The address on the account is still nobody's word but the user's, so signing in
              * would make the confirmation mail pointless: they would click the provider button
              * again and never confirm.  Send them to their inbox, with a fresh link each time,
              * because the first expires after seven days and for an account with no password
              * this is the only way to activate it.  An install that cannot send mail no longer
              * creates such an account, but guard rather than leave the user nothing to click. */
             resendActivateMail(conn, linked->email, linked->userName);
             cartSetString(cart, "hgLogin_actMailProvider", oauthProviderLabel(id->provider));
             cartSetString(cart, "hgLogin_actMailTo", linked->email);
             cartSetString(cart, "hgLogin_actMailUser", linked->userName);
+            /* Same hand-off, same stale-note risk as the plain signup above: nothing was
+             * changed here and no address was dropped, so say neither. */
+            cartRemove(cart, "hgLogin_actMailUnverified");
+            cartRemove(cart, "hgLogin_actMailChanged");
             /* Keep the identity signed in the cart so the page can offer to correct the
              * address.  Without that there is no way back at all for someone who mistyped it
              * when the account was made: they cannot sign in (this branch), cannot use the
              * email link or the change-email page (both want an activated account or a login
              * cookie), and cannot start again, because the user name and this provider identity
              * are both taken.  The signature is what lets changePendingEmail trust the request
              * that comes back: only a real provider round trip in this browser can mint it. */
             setPendingIdentity(id);
             gbMembersFree(&linked);
             gbMembersFreeList(&matches);
             displayActMailSuccess();
             return;
             }
         }
     loginAndReturn(conn, linked->userName, linked->idx);
@@ -3003,36 +3097,40 @@
     char *addrMatch = sqlAddressMatch(email);
     sqlSafef(query, sizeof(query),
         "SELECT count(*) FROM gbMembers WHERE %-s AND accountActivated='Y'", addrMatch);
     freeMem(addrMatch);
     bad = (sqlQuickNum(conn, query) > 0);
     }
 if (!bad)
     {
     char query[512];
     sqlSafef(query, sizeof(query),
         "UPDATE gbMembers SET email='%s', emailToken='', emailTokenExpires='' WHERE idx=%u",
         email, m->idx);
     sqlUpdate(conn, query);
     setupNewAccount(conn, email, m->userName);   // new address, so a new token is right
     cartSetString(cart, "hgLogin_actMailTo", email);
+    /* The page we are about to show is the same one the user just came from, so say that the
+     * change went through.  Otherwise the swapped-in address is the only sign of it. */
+    cartSetString(cart, "hgLogin_actMailChanged", "1");
     }
 else
     {
     freez(&errMsg);
     errMsg = cloneString("Please enter an email address that is not already in use.");
     cartSetString(cart, "hgLogin_actMailTo", m->email);
+    cartRemove(cart, "hgLogin_actMailChanged");
     }
 cartSetString(cart, "hgLogin_actMailProvider", oauthProviderLabel(provider));
 cartSetString(cart, "hgLogin_actMailUser", m->userName);
 cartRemove(cart, "hgLogin_email");
 gbMembersFree(&m);
 displayActMailSuccess();
 }
 
 void oauthStart(struct sqlConnection *conn)
 /* Begin a social login: save an anti-CSRF state nonce (in the cart) and redirect the
  * browser to the provider's authorization page. */
 {
 char *provider = cgiUsualString("provider", "");
 if (!oauthProviderEnabled(provider))
     {
@@ -3270,34 +3368,36 @@
 }
 
 static void dropRequestSuppliedFlowVars()
 /* The cart variables holding the state of a login in flight are written by hgLogin and by
  * nothing else: the nonce and provider of an OAuth round trip, the pending identity behind
  * the account chooser, and the verified address behind the email-link chooser.  The cart
  * takes CGI variables verbatim (loadCgiOverHash in hg/lib/cart.c), so a copy arriving with
  * the request would stand in for the copy we stored.  Drop those before anything reads them;
  * a flow whose state is dropped fails closed and the user starts it again.  Note that
  * excludeVars would not do this job: it governs what is saved at the end of a request, not
  * what is read during it. */
 {
 static char *serverOwned[] = {
     "oauth_state", "oauth_provider",
     "oauth_pending_provider", "oauth_pending_subject", "oauth_pending_email",
+    "oauth_pending_email_unverified",
     "oauth_pending_email_verified", "oauth_pending_name", "oauth_pending_time",
     "oauth_pending_sig",
     "emailLogin_email", "emailLogin_tokenMd5",
     "hgLogin_actMailProvider", "hgLogin_actMailTo", "hgLogin_actMailUser",
+    "hgLogin_actMailUnverified", "hgLogin_actMailChanged",
     };
 int i;
 for (i = 0;  i < ArraySize(serverOwned);  i++)
     if (cgiVarExists(serverOwned[i]))
         cartRemove(cart, serverOwned[i]);
 }
 
 void doMiddle(struct cart *theCart)
 /* Write the middle parts of the HTML page.
  * This routine sets up some globals and then
  * dispatches to the appropriate page-maker. */
 {
 struct sqlConnection *conn = hConnectCentral();
 
 // on mirrors, try to add the field 'recovEmail' to gbMembers. This may or may not work, depending on their config