packages/fxa-auth-server/lib/routes/emails.js (869 lines of code) (raw):

/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 'use strict'; const butil = require('../crypto/butil'); const emailUtils = require('./utils/email'); const error = require('../error'); const isA = require('joi'); const random = require('../crypto/random'); const Sentry = require('@sentry/node'); const validators = require('./validators'); const reportSentryError = require('../sentry'); const { emailsMatch, normalizeEmail } = require('fxa-shared').email.helpers; const { recordSecurityEvent } = require('./utils/security-event'); const EMAILS_DOCS = require('../../docs/swagger/emails-api').default; const DESCRIPTION = require('../../docs/swagger/shared/descriptions').default; const HEX_STRING = validators.HEX_STRING; const MAX_SECONDARY_EMAILS = 3; async function updateZendeskPrimaryEmail( zendeskClient, uid, currentPrimaryEmail, newPrimaryEmail ) { const searchResult = await zendeskClient.search.queryAll( `type:user user_id:${uid}` ); const zenUser = searchResult.find( (user) => user.email === currentPrimaryEmail ); if (!zenUser) { return; } const identityResult = await zendeskClient.useridentities.list(zenUser.id); const primaryIdentity = identityResult.find( (identity) => identity.type === 'email' && identity.primary && identity.value !== newPrimaryEmail ); if (!primaryIdentity) { return; } return zendeskClient.updateIdentity(zenUser.id, primaryIdentity.id, { identity: { verified: true, value: newPrimaryEmail, }, }); } /** * Update the primary email in Stripe * * @param {import('../payments/stripe').StripeHelper} stripeHelper * @param {string} uid * @param {string} currentPrimaryEmail * @param {string} newPrimaryEmail * @returns {Promise<void | import('stripe').Stripe.Customer>} */ async function updateStripeEmail( stripeHelper, uid, currentPrimaryEmail, newPrimaryEmail ) { const customer = await stripeHelper.fetchCustomer(uid); if (!customer || customer.email === newPrimaryEmail) { // No customer to update, or already updated. return; } return stripeHelper.stripe.customers.update(customer.id, { email: newPrimaryEmail, }); } module.exports = ( log, db, mailer, config, customs, push, verificationReminders, cadReminders, signupUtils, zendeskClient, /** @type import('../payments/stripe').StripeHelper */ stripeHelper, statsd ) => { const REMINDER_PATTERN = new RegExp( `^(?:${verificationReminders.keys.join('|')})$` ); const otpOptions = config.otp; const otpUtils = require('../../lib/routes/utils/otp')( log, config, db, statsd ); return [ { method: 'GET', path: '/recovery_email/status', options: { ...EMAILS_DOCS.RECOVERY_EMAIL_STATUS_GET, auth: { strategy: 'sessionToken', }, validate: { query: { reason: isA.string().max(16).optional(), }, }, response: { schema: isA.object({ // There's code in the handler that checks for a valid email, // no point adding overhead by doing it again here. email: isA.string().required(), verified: isA.boolean().required(), sessionVerified: isA.boolean().optional(), emailVerified: isA.boolean().optional(), }), }, }, handler: async function (request) { log.begin('Account.RecoveryEmailStatus', request); const sessionToken = request.auth.credentials; if (request.query && request.query.reason === 'push') { // log to the push namespace that account was verified via push log.info('push.pushToDevices', { name: 'recovery_email_reason.push', }); } await cleanUpIfAccountInvalid(); return createResponse(); async function cleanUpIfAccountInvalid() { const now = new Date().getTime(); const staleTime = now - config.emailStatusPollingTimeout; if (sessionToken.createdAt < staleTime) { log.info('recovery_email.status.stale', { email: sessionToken.email, createdAt: sessionToken.createdAt, lifeTime: sessionToken.lifetime, emailVerified: sessionToken.emailVerified, tokenVerified: sessionToken.tokenVerified, browser: `${sessionToken.uaBrowser} ${sessionToken.uaBrowserVersion}`, }); } if (!sessionToken.emailVerified) { // Some historical bugs mean we've allowed creation // of accounts with invalid email addresses. These // can never be verified, so the best we can do is // to delete them so the browser will stop polling. if ( !validators.isValidEmailAddress(sessionToken.email) && !(await stripeHelper.hasActiveSubscription(sessionToken.uid)) ) { await db.deleteAccount(sessionToken); log.info('accountDeleted.invalidEmailAddress', { ...sessionToken, }); // Act as though we deleted the account asynchronously // and caused the sessionToken to become invalid. throw error.invalidToken( 'This account was invalid and has been deleted' ); } } } function createResponse() { const sessionVerified = sessionToken.tokenVerified; const emailVerified = !!sessionToken.emailVerified; // For backwards-compatibility reasons, the reported verification status // depends on whether the sessionToken was created with keys=true and // whether it has subsequently been verified. If it was created with // keys=true then we musn't say verified=true until the session itself // has been verified. Otherwise, desktop clients will attempt to use // an unverified session to connect to sync, and produce a very confusing // user experience. let isVerified = emailVerified; if (sessionToken.mustVerify) { isVerified = isVerified && sessionVerified; } return { email: sessionToken.email, verified: isVerified, sessionVerified: sessionVerified, emailVerified: emailVerified, }; } }, }, { method: 'POST', path: '/recovery_email/resend_code', options: { ...EMAILS_DOCS.RECOVERY_EMAIL_RESEND_CODE_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { query: isA.object({ service: validators.service.description(DESCRIPTION.service), type: isA .string() .max(32) .alphanum() .allow('upgradeSession') .optional(), }), payload: isA.object({ email: validators.email().optional(), service: validators.service.description(DESCRIPTION.service), redirectTo: validators .redirectTo(config.smtp.redirectDomain) .optional(), resume: isA .string() .max(2048) .optional() .description(DESCRIPTION.resume), style: isA.string().allow('trailhead').optional(), type: isA .string() .max(32) .alphanum() .allow('upgradeSession') .optional(), }), }, }, handler: async function (request) { log.begin('Account.RecoveryEmailResend', request); const email = request.payload.email; const sessionToken = request.auth.credentials; const service = request.payload.service || request.query.service; const type = request.payload.type || request.query.type; const ip = request.app.clientAddress; const geoData = request.app.geo; const style = request.payload.style; // This endpoint can resend multiple types of codes, set these values once it // is known what is being verified. let code; let verifyFunction; let event; let emails = []; // Return immediately if this session or token is already verified. Only exception // is if the email param has been specified, which means that this is // a request to verify a secondary email. if ( sessionToken.emailVerified && sessionToken.tokenVerified && !email ) { return {}; } await customs.check( request, sessionToken.email, 'recoveryEmailResendCode' ); if (!(await setVerifyCode())) { return {}; } setVerifyFunction(); const { flowId, flowBeginTime } = await request.app.metricsContext; const mailerOpts = { code, deviceId: sessionToken.deviceId, flowId, flowBeginTime, service, ip, location: geoData.location, timeZone: geoData.timeZone, timestamp: Date.now(), redirectTo: request.payload.redirectTo, resume: request.payload.resume, acceptLanguage: request.app.acceptLanguage, uaBrowser: sessionToken.uaBrowser, uaBrowserVersion: sessionToken.uaBrowserVersion, uaOS: sessionToken.uaOS, uaOSVersion: sessionToken.uaOSVersion, uaDeviceType: sessionToken.uaDeviceType, uid: sessionToken.uid, style, }; await verifyFunction(emails, sessionToken, mailerOpts); await request.emitMetricsEvent(`email.${event}.resent`); return {}; // Returns a boolean to indicate whether to send email. async function setVerifyCode() { const emailData = await db.accountEmails(sessionToken.uid); if (email) { // If an email address is specified in payload, this is a request to verify // a secondary email. This should return the corresponding email code for verification. const foundEmail = emailData.find((userEmail) => emailsMatch(userEmail.normalizedEmail, email) ); // This user is attempting to verify a secondary email that doesn't belong to the account. if (!foundEmail) { throw error.cannotResendEmailCodeToUnownedEmail(); } emails = [foundEmail]; code = foundEmail.emailCode; return !foundEmail.isVerified; } else if (sessionToken.tokenVerificationId) { emails = emailData; code = sessionToken.tokenVerificationId; // Check to see if this account has a verified TOTP token. If so, then it should // not be allowed to bypass TOTP requirement by sending a sign-in confirmation email. try { const result = await db.totpToken(sessionToken.uid); if (result && result.verified && result.enabled) { return false; } return true; } catch (err) { if (err.errno === error.ERRNO.TOTP_TOKEN_NOT_FOUND) { return true; } throw err; } } else { code = sessionToken.emailCode; return true; } } function setVerifyFunction() { if (type && type === 'upgradeSession') { verifyFunction = mailer.sendVerifyPrimaryEmail; event = 'verification_email_primary'; } else if (!sessionToken.emailVerified) { verifyFunction = mailer.sendVerifyEmail; event = 'verification'; } else { verifyFunction = mailer.sendVerifyLoginEmail; event = 'confirmation'; } } }, }, { method: 'POST', path: '/recovery_email/verify_code', options: { ...EMAILS_DOCS.RECOVERY_EMAIL_VERIFY_CODE_POST, validate: { payload: isA.object({ uid: isA.string().max(32).regex(HEX_STRING).required(), code: isA.string().min(32).max(32).regex(HEX_STRING).required(), service: validators.service.description(DESCRIPTION.service), reminder: isA .string() .regex(REMINDER_PATTERN) .optional() .description(DESCRIPTION.reminder), type: isA .string() .max(32) .alphanum() .optional() .description(DESCRIPTION.type), style: isA.string().allow('trailhead').optional(), // The `marketingOptIn` is safe to remove after train-167+ marketingOptIn: isA.boolean().optional(), newsletters: validators.newsletters, }), }, }, handler: async function (request) { log.begin('Account.RecoveryEmailVerify', request); const { code, uid } = request.payload; // verify_code because we don't know what type this is yet, but // we want to record right away before anything could fail, so // we can see in a flow that a user tried to verify, even if it // failed right away. request.emitMetricsEvent('email.verify_code.clicked'); /** * Below is a summary of the verify_code flow. This flow is used to verify emails, sign-in and * account codes. * * 1) Check request against customs server, proceed if valid. * * 2) If type=`secondary` then this is an email code and verify it * accordingly. * * 3) Otherwise attempt to verify code as sign-in code then account code. */ const account = await db.account(uid); // This endpoint is not authenticated, so we need to look up // the target email address before we can check it with customs. await customs.check(request, account.email, 'recoveryEmailVerifyCode'); const isAccountVerification = butil.buffersAreEqual( code, account.emailCode ); let device; try { device = await db.deviceFromTokenVerificationId(uid, code); } catch (err) { if (err.errno !== error.ERRNO.DEVICE_UNKNOWN) { log.error('Account.RecoveryEmailVerify', { err, uid, code, }); } } await accountAndTokenVerification(isAccountVerification, account); if (device) { const devices = await request.app.devices; const otherDevices = devices.filter((d) => d.id !== device.id); await push.notifyDeviceConnected(uid, otherDevices, device.name); } // If the account is already verified, the link may have been // for sign-in confirmation or they may have been clicking a // stale link. Silently succeed. if (account.emailVerified) { return {}; } // Any matching code verifies the account await signupUtils.verifyAccount(request, account, request.payload); return {}; async function accountAndTokenVerification( isAccountVerification, account ) { /** * Logic for account and token verification * * 1) Attempt to use code as tokenVerificationId to verify session. * * 2) An error is thrown if tokenVerificationId does not exist (check to see if email * verification code) or the tokenVerificationId does not correlate to the * account uid (damaged linked/spoofed account) * * 3) Verify account email if not already verified. */ try { await db.verifyTokens(code, account); if (!isAccountVerification) { // Don't log sign-in confirmation success for the account verification case log.info('account.signin.confirm.success', { uid, code }); request.emitMetricsEvent('account.confirmed', { uid }); const devices = await request.app.devices; await push.notifyAccountUpdated(uid, devices, 'accountConfirm'); } } catch (err) { if ( err.errno === error.ERRNO.INVALID_VERIFICATION_CODE && isAccountVerification ) { // The code is just for the account, not for any sessions return; } log.error('account.signin.confirm.invalid', { err, uid, code, }); throw err; } } }, }, { method: 'GET', path: '/recovery_emails', options: { ...EMAILS_DOCS.RECOVERY_EMAILS_GET, auth: { strategy: 'sessionToken', }, response: { schema: isA.array().items( isA.object({ verified: isA.boolean().required(), isPrimary: isA.boolean().required(), email: validators.email().required(), }) ), }, }, handler: async function (request) { log.begin('Account.RecoveryEmailEmails', request); const sessionToken = request.auth.credentials; const uid = sessionToken.uid; const account = await db.account(uid); return account.emails.map((email) => ({ email: email.email, isPrimary: !!email.isPrimary, verified: !!email.isVerified, })); }, }, { method: 'POST', path: '/recovery_email', options: { ...EMAILS_DOCS.RECOVERY_EMAIL_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ email: validators .email() .required() .description(DESCRIPTION.emailAdd), }), }, response: {}, }, handler: async function (request) { log.begin('Account.RecoveryEmailCreate', request); const sessionToken = request.auth.credentials; const uid = sessionToken.uid; const primaryEmail = sessionToken.email; const { email } = request.payload; const emailData = { email: email, normalizedEmail: normalizeEmail(email), isVerified: false, isPrimary: false, uid: uid, }; await customs.check(request, primaryEmail, 'createEmail'); const account = await db.account(uid); const secondaryEmails = account.emails.filter( (email) => !email.isPrimary ); // This is compared against all secondary email // records, both verified and unverified if (secondaryEmails.length >= MAX_SECONDARY_EMAILS) { throw error.maxSecondaryEmailsReached(); } if (emailsMatch(sessionToken.email, email)) { throw error.yourPrimaryEmailExists(); } if ( account.emails .map((accountEmail) => accountEmail.email) .includes(email) ) { throw error.alreadyOwnsEmail(); } if (!sessionToken.emailVerified) { throw error.unverifiedAccount(); } if (sessionToken.tokenVerificationId) { throw error.unverifiedSession(); } await deleteAccountIfUnverified(); const hex = await random.hex(16); emailData.emailCode = hex; await db.createEmail(uid, emailData); const geoData = request.app.geo; try { await mailer.sendVerifySecondaryCodeEmail([emailData], sessionToken, { code: otpUtils.generateOtpCode(hex, otpOptions), deviceId: sessionToken.deviceId, acceptLanguage: request.app.acceptLanguage, email: emailData.email, primaryEmail, timeZone: geoData.timeZone, uaBrowser: sessionToken.uaBrowser, uaBrowserVersion: sessionToken.uaBrowserVersion, uaOS: sessionToken.uaOS, uaOSVersion: sessionToken.uaOSVersion, uid, }); } catch (err) { log.error('mailer.sendVerifySecondaryCodeEmail', { err: err }); await db.deleteEmail(emailData.uid, emailData.normalizedEmail); throw emailUtils.sendError(err, true); } recordSecurityEvent('account.secondary_email_added', { db, request, account, }); return {}; async function deleteAccountIfUnverified() { try { const secondaryEmailRecord = await db.getSecondaryEmail(email); if (secondaryEmailRecord.isPrimary) { if (secondaryEmailRecord.isVerified) { throw error.verifiedPrimaryEmailAlreadyExists(); } const msSinceCreated = Date.now() - secondaryEmailRecord.createdAt; const minUnverifiedAccountTime = config.secondaryEmail.minUnverifiedAccountTime; const exceedsMinUnverifiedAccountTime = msSinceCreated >= minUnverifiedAccountTime; if ( exceedsMinUnverifiedAccountTime && !(await stripeHelper.hasActiveSubscription( secondaryEmailRecord.uid )) ) { await db.deleteAccount(secondaryEmailRecord); log.info('accountDeleted.unverifiedSecondaryEmail', { ...secondaryEmailRecord, }); return; } else if (!exceedsMinUnverifiedAccountTime) { throw error.unverifiedPrimaryEmailNewlyCreated(); } else { throw error.unverifiedPrimaryEmailHasActiveSubscription(); } } // Only delete secondary email if it is unverified and does not belong // to the current user. if ( !secondaryEmailRecord.isVerified && !butil.buffersAreEqual(secondaryEmailRecord.uid, uid) ) { await db.deleteEmail( secondaryEmailRecord.uid, secondaryEmailRecord.email ); return; } } catch (err) { if (err.errno !== error.ERRNO.SECONDARY_EMAIL_UNKNOWN) { throw err; } } } }, }, { method: 'POST', path: '/recovery_email/destroy', options: { ...EMAILS_DOCS.RECOVERY_EMAIL_DESTROY_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ email: validators .email() .required() .description(DESCRIPTION.emailDelete), }), }, response: {}, }, handler: async function (request) { log.begin('Account.RecoveryEmailDestroy', request); const sessionToken = request.auth.credentials; const uid = sessionToken.uid; const primaryEmail = sessionToken.email; const email = request.payload.email; await customs.check(request, primaryEmail, 'deleteEmail'); const account = await db.account(uid); if (sessionToken.tokenVerificationId) { throw error.unverifiedSession(); } await db.deleteEmail(uid, normalizeEmail(email)); recordSecurityEvent('account.secondary_email_removed', { db, request }); await db.resetAccountTokens(uid); // Find the email object that corresponds to the email being deleted const emailIsVerified = account.emails.find((item) => { return emailsMatch(item.normalizedEmail, email) && item.isVerified; }); // Don't bother sending a notification if removing an email that was never verified if (!emailIsVerified) { return {}; } // Notify any verified email address associated with the account of the deletion. const emails = account.emails.filter((item) => { if (!emailsMatch(item.normalizedEmail, email)) { return item; } }); await mailer.sendPostRemoveSecondaryEmail(emails, account, { deviceId: sessionToken.deviceId, secondaryEmail: email, uid, }); return {}; }, }, { method: 'POST', path: '/recovery_email/set_primary', options: { ...EMAILS_DOCS.RECOVERY_EMAIL_SET_PRIMARY_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ email: validators .email() .required() .description(DESCRIPTION.emailNewPrimary), }), }, response: {}, }, handler: async function (request) { const sessionToken = request.auth.credentials; const { uid, verifierSetAt } = sessionToken; const currentEmail = sessionToken.email; const newEmail = request.payload.email; log.begin('Account.RecoveryEmailSetPrimary', request); await customs.check(request, currentEmail, 'setPrimaryEmail'); if (sessionToken.tokenVerificationId) { throw error.unverifiedSession(); } if (verifierSetAt <= 0) { throw error.unverifiedAccount(); } const newEmailRecord = await db.getSecondaryEmail(newEmail); if (newEmailRecord.uid !== uid) { throw error.cannotChangeEmailToUnownedEmail(); } if (!newEmailRecord.isVerified) { throw error.cannotChangeEmailToUnverifiedEmail(); } if (!newEmailRecord.isPrimary) { await db.setPrimaryEmail(uid, newEmailRecord.normalizedEmail); const devices = await request.app.devices; push.notifyProfileUpdated(uid, devices); log.notifyAttachedServices('primaryEmailChanged', request, { uid, email: newEmail, }); // While we typically do not want to capture PII in Sentry, in this // case we must record enough data for us to file a bug with Support // to update Zendesk so that this users' email matches their new primary. const handleCriticalError = (err, source) => { Sentry.withScope((scope) => { scope.setContext('primaryEmailChange', { originalEmail: currentEmail, newEmail: newEmailRecord.email, system: source, }); reportSentryError(err); }); }; // Fire off intentionally without waiting for all the network requests // required to update Zendesk/Stripe. Capture enough to manually update // Zendesk/Stripe if needed. updateZendeskPrimaryEmail( zendeskClient, uid, currentEmail, newEmailRecord.email ).catch((err) => handleCriticalError(err, 'zendesk')); if (stripeHelper) { // Wait here to update stripe and our local cache to avoid loss of // valid subscription status. try { await updateStripeEmail( stripeHelper, uid, currentEmail, newEmailRecord.email ); } catch (err) { // Due to the work involved by this point, we cannot abort the // request. We instead report it for manual fixing with sufficient // context to locate the user and update Stripe and our cache. handleCriticalError(err, 'stripe'); } } const account = await db.account(uid); await mailer.sendPostChangePrimaryEmail(account.emails, account, { acceptLanguage: request.app.acceptLanguage, uid, }); recordSecurityEvent('account.primary_secondary_swapped', { db, request, }); } return {}; }, }, { method: 'POST', path: '/recovery_email/secondary/resend_code', options: { ...EMAILS_DOCS.RECOVERY_EMAIL_SECONDARY_RESEND_CODE_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ email: validators .email() .description(DESCRIPTION.emailSecondaryVerify) .required(), }), }, response: {}, }, handler: async function (request) { log.begin('Account.RecoveryEmailSecondaryResend', request); const sessionToken = request.auth.credentials; const geoData = request.app.geo; const { email } = request.payload; await customs.check( request, sessionToken.email, 'recoveryEmailSecondaryResendCode' ); const { deviceId, uaBrowser, uaBrowserVersion, uaOS, uaOSVersion, uaDeviceType, uid, } = sessionToken; const account = await db.account(uid); const emails = await db.accountEmails(uid); // Get the secondary email code const foundEmail = emails.find((userEmail) => emailsMatch(userEmail.normalizedEmail, email) ); // This user is attempting to verify a secondary email that doesn't belong to the account. if (!foundEmail) { throw error.cannotResendEmailCodeToUnownedEmail(); } const secret = foundEmail.emailCode; const code = otpUtils.generateOtpCode(secret, otpOptions); const mailerOpts = { code, deviceId, timeZone: geoData.timeZone, timestamp: Date.now(), acceptLanguage: request.app.acceptLanguage, uaBrowser, uaBrowserVersion, uaOS, uaOSVersion, uaDeviceType, uid, }; await mailer.sendVerifySecondaryCodeEmail( [foundEmail], account, mailerOpts ); return {}; }, }, { method: 'POST', path: '/recovery_email/secondary/verify_code', options: { ...EMAILS_DOCS.RECOVERY_EMAIL_SECONDARY_VERIFY_CODE_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ email: validators .email() .required() .description(DESCRIPTION.emailSecondaryVerify), code: isA .string() .max(32) .regex(validators.DIGITS) .description(DESCRIPTION.code) .required(), }), }, }, handler: async function (request) { log.begin('Account.RecoveryEmailSecondaryVerify', request); const sessionToken = request.auth.credentials; const { email, code } = request.payload; await customs.check( request, sessionToken.email, 'recoveryEmailSecondaryVerifyCode' ); const { uid } = sessionToken; const account = await db.account(uid); const emails = await db.accountEmails(uid); // Get the secondary email code const matchedEmail = emails.find((userEmail) => emailsMatch(userEmail.normalizedEmail, email) ); if (!matchedEmail) { throw error.invalidVerificationCode(); } const secret = matchedEmail.emailCode; const isValid = otpUtils.verifyOtpCode( code, secret, otpOptions, 'recovery_email.secondary.verify_code' ); if (!isValid) { throw error.invalidVerificationCode(); } // User is attempting to verify a secondary email that has already been verified. // Silently succeed and don't send post verification email. if (matchedEmail.isVerified) { log.info('account.verifyEmail.secondary.already-verified', { uid, }); return {}; } await db.verifyEmail(account, matchedEmail.emailCode); log.info('account.verifyEmail.secondary.confirmed', { uid, }); await mailer.sendPostVerifySecondaryEmail([], account, { acceptLanguage: request.app.acceptLanguage, secondaryEmail: matchedEmail.email, uid, }); return {}; }, }, { method: 'POST', path: '/emails/reminders/cad', options: { ...EMAILS_DOCS.EMAILS_REMINDERS_CAD_POST, auth: { strategy: 'sessionToken', payload: 'required', }, }, handler: async function (request) { log.begin('Account.CadReminderEmail', request); const sessionToken = request.auth.credentials; const uid = sessionToken.uid; const reminders = await cadReminders.get(uid); const exists = cadReminders.keys.some((key) => { return reminders[key] !== null; }); if (!exists) { await cadReminders.create(uid); log.info('cad.reminder.created', { uid, }); } else { log.info('cad.reminder.exists', { uid, }); } return {}; }, }, ]; }; // Exported for testing purposes. module.exports._updateZendeskPrimaryEmail = updateZendeskPrimaryEmail; module.exports._updateStripeEmail = updateStripeEmail;