diff --git a/libs/accounts/email-renderer/src/renderer/email-link-builder.ts b/libs/accounts/email-renderer/src/renderer/email-link-builder.ts index ad9d9ff63ab..58db852e7d5 100644 --- a/libs/accounts/email-renderer/src/renderer/email-link-builder.ts +++ b/libs/accounts/email-renderer/src/renderer/email-link-builder.ts @@ -424,6 +424,40 @@ export class EmailLinkBuilder { buildDesktopLink() { return this.config.firefoxDesktopUrl; } + + buildVerifyEmailLink( + templateName: string, + metricsEnabled: boolean, + query: { + code: string; + uid: string; + resume?: string; + redirectTo?: string; + service?: string; + } + ): string { + const url = new URL(`${this.baseUri}/verify_email`); + if (this.config.prependVerificationSubdomain.enabled) { + url.host = `${this.config.prependVerificationSubdomain.subdomain}.${url.host}`; + } + this.addUTMParams(url, templateName, metricsEnabled); + this.addQueryParams(url, query); + return url.toString(); + } + + buildReportSignInLink( + templateName: string, + metricsEnabled: boolean, + query: { + uid: string; + unblockCode: string; + } + ): string { + const url = new URL(`${this.baseUri}/report_signin`); + this.addUTMParams(url, templateName, metricsEnabled, 'report'); + this.addQueryParams(url, query); + return url.toString(); + } } // PORTED FROM fxa-shared/subscriptions/configuration/utils.ts diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index 4f2672f5cd1..e466b205193 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -403,28 +403,45 @@ export class AccountHandler { break; } default: { - await this.mailer.sendVerifyEmail([], account, { - code: account.emailCode, - service: form.service || query.service, - redirectTo: form.redirectTo, - resume: form.resume, - acceptLanguage: locale, - deviceId, - flowId, - flowBeginTime, - productId, - planId, - ip, - location: request.app.geo.location, - timeZone: request.app.geo.timeZone, - style, - uaBrowser: sessionToken.uaBrowser, - uaBrowserVersion: sessionToken.uaBrowserVersion, - uaOS: sessionToken.uaOS, - uaOSVersion: sessionToken.uaOSVersion, - uaDeviceType: sessionToken.uaDeviceType, - uid: sessionToken.uid, - }); + if (this.fxaMailer.canSend('verify')) { + await this.fxaMailer.sendVerifyEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.sync(form.service || query.service), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + code: account.emailCode, + resume: form.resume, + redirectTo: form.redirectTo, + service: form.service || query.service, + }); + } else { + console.debug('falling back') + const sent = await this.mailer.sendVerifyEmail([], account, { + code: account.emailCode, + service: form.service || query.service, + redirectTo: form.redirectTo, + resume: form.resume, + acceptLanguage: locale, + deviceId, + flowId, + flowBeginTime, + productId, + planId, + ip, + location: request.app.geo.location, + timeZone: request.app.geo.timeZone, + style, + uaBrowser: sessionToken.uaBrowser, + uaBrowserVersion: sessionToken.uaBrowserVersion, + uaOS: sessionToken.uaOS, + uaOSVersion: sessionToken.uaOSVersion, + uaDeviceType: sessionToken.uaDeviceType, + uid: sessionToken.uid, + }); + console.debug('falling back sent!', sent); + } } } diff --git a/packages/fxa-auth-server/lib/routes/emails.js b/packages/fxa-auth-server/lib/routes/emails.js index 9fe2d89257a..5c58d23f537 100644 --- a/packages/fxa-auth-server/lib/routes/emails.js +++ b/packages/fxa-auth-server/lib/routes/emails.js @@ -236,32 +236,45 @@ module.exports = ( const geoData = request.app.geo; try { - await mailer.sendVerifySecondaryCodeEmail( - [ + if (fxaMailer.canSend('verifySecondaryCode')) { + await fxaMailer.sendVerifySecondaryCodeEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + code: otpUtils.generateOtpCode(secret, otpOptions), + email, + }); + } else { + await mailer.sendVerifySecondaryCodeEmail( + [ + { + email, + normalizedEmail, + isVerified: false, + isPrimary: false, + uid, + }, + ], + sessionToken, { + code: otpUtils.generateOtpCode(secret, otpOptions), + deviceId: sessionToken.deviceId, + acceptLanguage: request.app.acceptLanguage, email, - normalizedEmail, - isVerified: false, - isPrimary: false, + primaryEmail, + location: geoData.location, + timeZone: geoData.timeZone, + uaBrowser: sessionToken.uaBrowser, + uaBrowserVersion: sessionToken.uaBrowserVersion, + uaOS: sessionToken.uaOS, + uaOSVersion: sessionToken.uaOSVersion, uid, - }, - ], - sessionToken, - { - code: otpUtils.generateOtpCode(secret, otpOptions), - deviceId: sessionToken.deviceId, - acceptLanguage: request.app.acceptLanguage, - email, - primaryEmail, - location: geoData.location, - timeZone: geoData.timeZone, - uaBrowser: sessionToken.uaBrowser, - uaBrowserVersion: sessionToken.uaBrowserVersion, - uaOS: sessionToken.uaOS, - uaOSVersion: sessionToken.uaOSVersion, - uid, - } - ); + } + ); + } } catch (err) { log.error('secondary_email.sendVerifySecondaryCodeEmail.error', { err: err, @@ -764,8 +777,6 @@ module.exports = ( // 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 @@ -790,9 +801,8 @@ module.exports = ( return {}; } - setVerifyFunction(); - const { flowId, flowBeginTime } = await request.app.metricsContext; + const account = await db.account(sessionToken.uid); const mailerOpts = { code, @@ -816,8 +826,69 @@ module.exports = ( style, }; - await verifyFunction(emails, sessionToken, mailerOpts); - await request.emitMetricsEvent(`email.${event}.resent`); + if (type && type === 'upgradeSession') { + if (fxaMailer.canSend('verifyPrimary')) { + await fxaMailer.sendVerifyPrimaryEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + code, + service, + redirectTo: request.payload.redirectTo, + resume: request.payload.resume, + }); + } else { + await mailer.sendVerifyPrimaryEmail( + emails, + sessionToken, + mailerOpts + ); + } + await request.emitMetricsEvent( + `email.verification_email_primary.resent` + ); + } else if (!sessionToken.emailVerified) { + if (fxaMailer.canSend('verify')) { + await fxaMailer.sendVerifyEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + code, + service, + redirectTo: request.payload.redirectTo, + resume: request.payload.resume, + }); + } else { + await mailer.sendVerifyEmail(emails, sessionToken, mailerOpts); + } + await request.emitMetricsEvent(`email.verification.resent`); + } else { + if (fxaMailer.canSend('verifyLogin')) { + await fxaMailer.sendVerifyLoginEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + code, + service, + clientName: 'Firefox', + redirectTo: request.payload.redirectTo, + resume: request.payload.resume, + }); + } else { + await mailer.sendVerifyLoginEmail(emails, sessionToken, mailerOpts); + } + await request.emitMetricsEvent(`email.confirmation.resent`); + } + return {}; // Returns a boolean to indicate whether to send email. @@ -863,19 +934,6 @@ module.exports = ( 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'; - } - } }, }, { @@ -1334,6 +1392,7 @@ module.exports = ( const sessionToken = request.auth.credentials; const { email } = request.payload; const normalizedEmail = normalizeEmail(email); + const account = await db.account(sessionToken.uid); await customs.checkAuthenticated( request, @@ -1424,33 +1483,46 @@ module.exports = ( const geoData = request.app.geo; try { - await mailer.sendVerifySecondaryCodeEmail( - [ + if (fxaMailer.canSend('verifySecondaryCode')) { + await fxaMailer.sendVerifySecondaryCodeEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + code, + email, + }); + } else { + await mailer.sendVerifySecondaryCodeEmail( + [ + { + email, + normalizedEmail, + isVerified: false, + isPrimary: false, + uid, + }, + ], + sessionToken, { + code, + deviceId, + acceptLanguage: request.app.acceptLanguage, email, - normalizedEmail, - isVerified: false, - isPrimary: false, + primaryEmail: sessionToken.email, + location: geoData.location, + timeZone: geoData.timeZone, + uaBrowser, + uaBrowserVersion, + uaOS, + uaOSVersion, + uaDeviceType, uid, - }, - ], - sessionToken, - { - code, - deviceId, - acceptLanguage: request.app.acceptLanguage, - email, - primaryEmail: sessionToken.email, - location: geoData.location, - timeZone: geoData.timeZone, - uaBrowser, - uaBrowserVersion, - uaOS, - uaOSVersion, - uaDeviceType, - uid, - } - ); + } + ); + } } catch (err) { log.error('secondary_email.resendVerifySecondaryCodeEmail.error', { err: err, diff --git a/packages/fxa-auth-server/lib/routes/mfa.ts b/packages/fxa-auth-server/lib/routes/mfa.ts index 6f9af2b5047..d95b43533d6 100644 --- a/packages/fxa-auth-server/lib/routes/mfa.ts +++ b/packages/fxa-auth-server/lib/routes/mfa.ts @@ -14,6 +14,8 @@ import { recordSecurityEvent } from './utils/security-event'; import { ConfigType } from '../../config'; import { OtpUtils } from './utils/otp'; import { AppError } from '@fxa/accounts/errors'; +import { FxaMailer } from '../senders/fxa-mailer'; +import { FxaMailerFormat } from '../senders/fxa-mailer-format'; /** Customs interface for mfa specific operations. */ interface Customs { @@ -49,6 +51,7 @@ const toPascal = (str) => str.replace(/(^|_)(\w)/g, (_, __, c) => c.toUpperCase()); class MfaHandler { + private fxaMailer: FxaMailer; constructor( private readonly config: ConfigType, private readonly db: DB, @@ -56,7 +59,9 @@ class MfaHandler { private readonly customs: Customs, private readonly mailer: Mailer, private readonly statsd: StatsD - ) {} + ) { + this.fxaMailer = Container.get(FxaMailer); + } async requestOtpCode(request: AuthRequest) { const { uid, id: sessionTokenId } = request.auth @@ -84,12 +89,32 @@ class MfaHandler { // is valid for 30s. // For specifics see: https://www.npmjs.com/package/otplib const expirationTime = (options.step * options.window) / 60; - - await this.mailer.sendVerifyAccountChangeEmail(account.emails, account, { - code, - uid, - expirationTime, - }); + if (this.fxaMailer.canSend('verifyAccountChange')) { + await this.fxaMailer.sendVerifyAccountChangeEmail({ + ...FxaMailerFormat.account( + // probably just update the type interface above + account as Account & + Required> + ), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + code, + expirationTime, + }); + } else { + await this.mailer.sendVerifyAccountChangeEmail( + account.emails, + account, + { + code, + uid, + expirationTime, + } + ); + } success = true; } catch (error) { diff --git a/packages/fxa-auth-server/lib/routes/password.ts b/packages/fxa-auth-server/lib/routes/password.ts index 4309d46e8ff..775c35ca08f 100644 --- a/packages/fxa-auth-server/lib/routes/password.ts +++ b/packages/fxa-auth-server/lib/routes/password.ts @@ -458,18 +458,29 @@ module.exports = function ( } = request.app.ua; try { - await mailer.sendPasswordChangedEmail(emails, account, { - acceptLanguage: request.app.acceptLanguage, - ip, - location: geoData.location, - timeZone: geoData.timeZone, - uaBrowser, - uaBrowserVersion, - uaOS, - uaOSVersion, - uaDeviceType, - uid: passwordChangeToken.uid, - }); + if (fxaMailer.canSend('passwordChanged')) { + await fxaMailer.sendPasswordChangedEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + await mailer.sendPasswordChangedEmail(emails, account, { + acceptLanguage: request.app.acceptLanguage, + ip, + location: geoData.location, + timeZone: geoData.timeZone, + uaBrowser, + uaBrowserVersion, + uaOS, + uaOSVersion, + uaDeviceType, + uid: passwordChangeToken.uid, + }); + } } catch (error) { // If we couldn't email them, no big deal. Log // and pretend everything worked. @@ -846,18 +857,29 @@ module.exports = function ( } = request.app.ua; try { - await mailer.sendPasswordChangedEmail(emails, account, { - acceptLanguage: request.app.acceptLanguage, - ip, - location: geoData.location, - timeZone: geoData.timeZone, - uaBrowser, - uaBrowserVersion, - uaOS, - uaOSVersion, - uaDeviceType, - uid: uid, - }); + if (fxaMailer.canSend('passwordChanged')) { + await fxaMailer.sendPasswordChangedEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + await mailer.sendPasswordChangedEmail(emails, account, { + acceptLanguage: request.app.acceptLanguage, + ip, + location: geoData.location, + timeZone: geoData.timeZone, + uaBrowser, + uaBrowserVersion, + uaOS, + uaOSVersion, + uaDeviceType, + uid: uid, + }); + } } catch (error) { // If we couldn't email them, no big deal. Log // and pretend everything worked. @@ -1216,12 +1238,13 @@ module.exports = function ( }); } - const [, emails] = await Promise.all([ + const [, emails, account] = await Promise.all([ request.propagateMetricsContext( passwordForgotToken, accountResetToken ), db.accountEmails(passwordForgotToken.uid), + db.account(passwordForgotToken.uid), ]); const { @@ -1257,11 +1280,22 @@ module.exports = function ( emailOptions ); } else { - await mailer.sendPasswordResetEmail( - emails, - passwordForgotToken, - emailOptions - ); + if (fxaMailer.canSend('passwordReset')) { + await fxaMailer.sendPasswordResetEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + await mailer.sendPasswordResetEmail( + emails, + passwordForgotToken, + emailOptions + ); + } } } diff --git a/packages/fxa-auth-server/lib/routes/unblock-codes.js b/packages/fxa-auth-server/lib/routes/unblock-codes.js index 96343293abf..9571a5c846d 100644 --- a/packages/fxa-auth-server/lib/routes/unblock-codes.js +++ b/packages/fxa-auth-server/lib/routes/unblock-codes.js @@ -13,9 +13,13 @@ const METRICS_CONTEXT_SCHEMA = require('../metrics/context').schema; const validators = require('./validators'); const { HEX_STRING, BASE_36 } = validators; +const { Container } = require('typedi'); +const { FxaMailer } = require('../senders/fxa-mailer'); +const { FxaMailerFormat } = require('../senders/fxa-mailer-format'); module.exports = (log, db, mailer, config, customs) => { const unblockCodeLen = (config && config.codeLength) || 0; + const fxaMailer = Container.get(FxaMailer); return [ { @@ -54,21 +58,32 @@ module.exports = (log, db, mailer, config, customs) => { osVersion: uaOSVersion, deviceType: uaDeviceType, } = request.app.ua; - - await mailer.sendUnblockCodeEmail(emails, emailRecord, { - acceptLanguage: request.app.acceptLanguage, - unblockCode, - flowId, - flowBeginTime, - location: request.app.geo.location, - timeZone: request.app.geo.timeZone, - uaBrowser, - uaBrowserVersion, - uaOS, - uaOSVersion, - uaDeviceType, - uid, - }); + if (fxaMailer.canSend('unblockCode')) { + await fxaMailer.sendUnblockCodeEmail({ + ...FxaMailerFormat.account(emailRecord), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + unblockCode, + }); + } else { + await mailer.sendUnblockCodeEmail(emails, emailRecord, { + acceptLanguage: request.app.acceptLanguage, + unblockCode, + flowId, + flowBeginTime, + location: request.app.geo.location, + timeZone: request.app.geo.timeZone, + uaBrowser, + uaBrowserVersion, + uaOS, + uaOSVersion, + uaDeviceType, + uid, + }); + } await request.emitMetricsEvent('account.login.sentUnblockCode'); diff --git a/packages/fxa-auth-server/lib/routes/utils/signin.js b/packages/fxa-auth-server/lib/routes/utils/signin.js index b7aa9c3391c..efa36154440 100644 --- a/packages/fxa-auth-server/lib/routes/utils/signin.js +++ b/packages/fxa-auth-server/lib/routes/utils/signin.js @@ -16,6 +16,8 @@ const otp = require('../utils/otp'); const { fetchRpCmsData } = require('../utils/account'); const { RelyingPartyConfigurationManager } = require('@fxa/shared/cms'); const requestHelper = require('./request_helper'); +const { FxaMailer } = require('../../senders/fxa-mailer'); +const { FxaMailerFormat } = require('../../senders/fxa-mailer-format'); const BASE_36 = validators.BASE_36; @@ -49,6 +51,8 @@ module.exports = ( ? Container.get(RelyingPartyConfigurationManager) : null; + const fxaMailer = Container.get(FxaMailer); + return { validators: { UNBLOCK_CODE: isA @@ -421,23 +425,38 @@ module.exports = ( sessionToken.tokenVerificationId || accountRecord.primaryEmail.emailCode; try { - await mailer.sendVerifyEmail([], accountRecord, { - code: emailCode, - service, - redirectTo, - resume, - acceptLanguage: request.app.acceptLanguage, - deviceId, - flowId, - flowBeginTime, - timeZone: request.app.geo.timeZone, - uaBrowser: request.app.ua.browser, - uaBrowserVersion: request.app.ua.browserVersion, - uaOS: request.app.ua.os, - uaOSVersion: request.app.ua.osVersion, - uaDeviceType: request.app.ua.deviceType, - uid: sessionToken.uid, - }); + if (fxaMailer.canSend('verify')) { + await fxaMailer.sendVerifyEmail({ + ...FxaMailerFormat.account(accountRecord), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + code: emailCode, + resume, + redirectTo, + service + }); + } else { + await mailer.sendVerifyEmail([], accountRecord, { + code: emailCode, + service, + redirectTo, + resume, + acceptLanguage: request.app.acceptLanguage, + deviceId, + flowId, + flowBeginTime, + timeZone: request.app.geo.timeZone, + uaBrowser: request.app.ua.browser, + uaBrowserVersion: request.app.ua.browserVersion, + uaOS: request.app.ua.os, + uaOSVersion: request.app.ua.osVersion, + uaDeviceType: request.app.ua.deviceType, + uid: sessionToken.uid, + }); + } await request.emitMetricsEvent('email.verification.sent'); } catch (err) { log.error('mailer.verification.error', { @@ -484,28 +503,45 @@ module.exports = ( const geoData = request.app.geo; try { - await mailer.sendVerifyLoginEmail( - accountRecord.emails, - accountRecord, - { - acceptLanguage: request.app.acceptLanguage, + if (fxaMailer.canSend('verifyLogin')) { + await fxaMailer.sendVerifyLoginEmail({ + ...FxaMailerFormat.account(accountRecord), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), code: sessionToken.tokenVerificationId, - deviceId, - flowId, - flowBeginTime, + clientName: 'Firefox', redirectTo: redirectTo, - resume: resume, service: service, - location: geoData.location, - timeZone: geoData.timeZone, - uaBrowser: request.app.ua.browser, - uaBrowserVersion: request.app.ua.browserVersion, - uaOS: request.app.ua.os, - uaOSVersion: request.app.ua.osVersion, - uaDeviceType: request.app.ua.deviceType, + resume: resume, uid: sessionToken.uid, - } - ); + }); + } else { + await mailer.sendVerifyLoginEmail( + accountRecord.emails, + accountRecord, + { + acceptLanguage: request.app.acceptLanguage, + code: sessionToken.tokenVerificationId, + deviceId, + flowId, + flowBeginTime, + redirectTo: redirectTo, + resume: resume, + service: service, + location: geoData.location, + timeZone: geoData.timeZone, + uaBrowser: request.app.ua.browser, + uaBrowserVersion: request.app.ua.browserVersion, + uaOS: request.app.ua.os, + uaOSVersion: request.app.ua.osVersion, + uaDeviceType: request.app.ua.deviceType, + uid: sessionToken.uid, + } + ); + } await request.emitMetricsEvent('email.confirmation.sent'); } catch (err) { log.error('mailer.confirmation.error', { diff --git a/packages/fxa-auth-server/lib/senders/fxa-mailer-sanity-check.ts b/packages/fxa-auth-server/lib/senders/fxa-mailer-sanity-check.ts index dd2f29cd7d0..fe626b98fd1 100644 --- a/packages/fxa-auth-server/lib/senders/fxa-mailer-sanity-check.ts +++ b/packages/fxa-auth-server/lib/senders/fxa-mailer-sanity-check.ts @@ -396,3 +396,114 @@ async function __sendPostVerifyEmail() { onDesktopOrTabletDevice: true, }); } + +async function __sendVerifyEmail() { + await fxaMailer.sendVerifyEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + code: 'todo', + resume: 'todo', + }); +} + +async function __sendVerifyLoginEmail() { + await fxaMailer.sendVerifyLoginEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + code: '123456', + clientName: 'Firefox', + redirectTo: 'https://example.com', + resume: 'resumeToken', + }); +} + +async function __sendUnblockCodeEmail() { + await fxaMailer.sendUnblockCodeEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + unblockCode: 'ABCD1234', + }); +} + +async function __sendPasswordChangedEmail() { + await fxaMailer.sendPasswordChangedEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPasswordChangeRequiredEmail() { + await fxaMailer.sendPasswordChangeRequiredEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPasswordResetEmail() { + await fxaMailer.sendPasswordResetEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendVerifyPrimaryEmail() { + await fxaMailer.sendVerifyPrimaryEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + code: 'VERIFY123', + }); +} + +async function __sendVerifySecondaryCodeEmail() { + await fxaMailer.sendVerifySecondaryCodeEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + code: 'SEC456', + email: FxaMailerFormat.account(account).to, + }); +} + +async function __sendVerifyAccountChangeEmail() { + await fxaMailer.sendVerifyAccountChangeEmail({ + ...FxaMailerFormat.account(account), + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + code: 'CHANGE789', + expirationTime: 5, + }); +} diff --git a/packages/fxa-auth-server/lib/senders/fxa-mailer.ts b/packages/fxa-auth-server/lib/senders/fxa-mailer.ts index f9f7c613453..b4b6540a5a7 100644 --- a/packages/fxa-auth-server/lib/senders/fxa-mailer.ts +++ b/packages/fxa-auth-server/lib/senders/fxa-mailer.ts @@ -36,16 +36,20 @@ import { postVerify, verifyLoginCode, verifyShortCode, + verify, + verifyLogin, + unblockCode, + passwordChanged, + passwordChangeRequired, + passwordReset, + verifyPrimary, + verifySecondaryCode, + verifyAccountChange, } from '@fxa/accounts/email-renderer'; import { EmailSender } from '@fxa/accounts/email-sender'; import { FxaEmailRenderer } from '@fxa/accounts/email-renderer'; import { ConfigType } from '../../config'; -import moment from 'moment-timezone'; -import { metrics } from '@opentelemetry/api'; -import { TemplateInstance } from 'twilio/lib/rest/verify/v2/template'; -import { r } from '@faker-js/faker/dist/airline-BUL6NtOJ'; -import otp from '../routes/utils/otp'; const SERVER = 'fxa-auth-server'; @@ -88,6 +92,7 @@ type OmitCommonLinks = Omit< | 'twoFactorSettingsLink' | 'resetLink' | 'desktopLink' + | 'reportSignInLink' | K >; @@ -675,11 +680,9 @@ export class FxaMailer extends FxaEmailRenderer { resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { email: opts.to, }), - link: this.linkBuilder.buildAccountSettingsLink( - template, - opts.metricsEnabled, - { email: opts.to, uid: opts.uid } - ), + link: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), }; const headers = this.buildHeaders( { template, version }, @@ -1057,10 +1060,373 @@ export class FxaMailer extends FxaEmailRenderer { return this.sendEmail(opts, headers, rendered); } + async sendVerifyEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks & { + code: string; + service?: string; + redirectTo?: string; + resume?: string; + } + ) { + const { template, version } = verify; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildVerifyEmailLink(template, metricsEnabled, { + code: opts.code, + uid: opts.uid, + service: opts.service, + redirectTo: opts.redirectTo, + resume: opts.resume, + }), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link, 'X-Verify-Code': opts.code }, + opts + ); + const rendered = await this.renderVerify({ + ...opts, + ...links, + }); + return await this.sendEmail(opts, headers, rendered); + } + + async sendVerifyLoginEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks & { + code: string; + service?: string; + redirectTo?: string; + resume?: string; + } + ) { + const { template, version } = verifyLogin; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildVerifyLoginLink(template, metricsEnabled, { + code: opts.code, + uid: opts.uid, + service: opts.service, + redirectTo: opts.redirectTo, + resume: opts.resume, + }), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link, 'X-Verify-Code': opts.code }, + opts + ); + const rendered = await this.renderVerifyLogin({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendUnblockCodeEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks & { + unblockCode: string; + } + ) { + const { template, version } = unblockCode; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildAccountSettingsLink( + template, + metricsEnabled, + { email: opts.to, uid: opts.uid } + ), + reportSignInLink: this.linkBuilder.buildReportSignInLink( + template, + metricsEnabled, + { uid: opts.uid, unblockCode: opts.unblockCode } + ), + }; + const headers = this.buildHeaders( + { template, version }, + { + 'X-Unblock-Code': opts.unblockCode, + 'X-Report-SignIn-Link': links.reportSignInLink, + }, + opts + ); + const rendered = await this.renderUnblockCode({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPasswordChangedEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = passwordChanged; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), + link: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.resetLink }, + opts + ); + const rendered = await this.renderPasswordChanged({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPasswordChangeRequiredEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = passwordChangeRequired; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeRequiredLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildPasswordChangeRequiredLink( + template, + metricsEnabled, + { email: opts.to } + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.passwordChangeLink }, + opts + ); + const rendered = await this.renderPasswordChangeRequired({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPasswordResetEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = passwordReset; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), + link: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPasswordReset({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendVerifyPrimaryEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks & { + code: string; + service?: string; + redirectTo?: string; + resume?: string; + } + ) { + const { template, version } = verifyPrimary; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildVerifyEmailLink(template, metricsEnabled, { + code: opts.code, + uid: opts.uid, + service: opts.service, + redirectTo: opts.redirectTo, + resume: opts.resume, + }), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link, 'X-Verify-Code': opts.code }, + opts + ); + const rendered = await this.renderVerifyPrimary({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + /** + * Email sent to secondary email for verification. Uses `opts.email` as the `to` address + * @param opts + * @returns + */ + async sendVerifySecondaryCodeEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks & { + code: string; + service?: string; + redirectTo?: string; + resume?: string; + } + ) { + // We assume that the inbound options.to is the primary account email + // however, the .email property is the secondary email to send to + const { template, version } = verifySecondaryCode; + const { metricsEnabled, to: primaryEmail, email: secondaryEmail } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: primaryEmail } + ), + link: this.linkBuilder.buildVerifyEmailLink(template, metricsEnabled, { + code: opts.code, + uid: opts.uid, + service: opts.service, + redirectTo: opts.redirectTo, + resume: opts.resume, + }), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Verify-Code': opts.code }, + opts + ); + const rendered = await this.renderVerifySecondaryCode({ + ...opts, + ...links, + }); + // explicitly override the `to` to ensure we send to the right email + return this.sendEmail({ ...opts, to: secondaryEmail }, headers, rendered); + } + + async sendVerifyAccountChangeEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks & { + code: string; + service?: string; + redirectTo?: string; + resume?: string; + } + ) { + const { template, version } = verifyAccountChange; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildVerifyEmailLink(template, metricsEnabled, { + code: opts.code, + uid: opts.uid, + service: opts.service, + redirectTo: opts.redirectTo, + resume: opts.resume, + }), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Account-Change-Verify-Code': opts.code }, + opts + ); + const rendered = await this.renderVerifyAccountChange({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + private buildHeaders( template: { template: string; version: number }, headers: Record, - opts: { acceptLanguage: string } + opts: { + acceptLanguage: string; + service?: string; + deviceId?: string; + flowId?: string; + flowBeginTime?: number; + uid?: string; + } ) { return this.emailSender.buildHeaders({ context: { @@ -1085,6 +1451,7 @@ export class FxaMailer extends FxaEmailRenderer { const from = cmsRpFromName ? `${cmsRpFromName} <${this.mailerConfig.sender}>` : this.mailerConfig.sender; + console.debug(`!!! From: ${from} !!!`); return this.emailSender.send({ to, diff --git a/packages/fxa-auth-server/scripts/delete-account.ts b/packages/fxa-auth-server/scripts/delete-account.ts index 1fb571a5652..c7c03d59c27 100755 --- a/packages/fxa-auth-server/scripts/delete-account.ts +++ b/packages/fxa-auth-server/scripts/delete-account.ts @@ -20,6 +20,7 @@ import { StatsD } from 'hot-shots'; import readline from 'readline'; import { Container } from 'typedi'; +import { join } from 'path'; import { PayPalClient } from '@fxa/payments/paypal'; @@ -161,7 +162,12 @@ DB.connect(config).then(async (db: any) => { emailSender, linkBuilder, config.smtp, - new NodeRendererBindings() + new NodeRendererBindings({ + translations: { + basePath: join(__dirname, '../public/locales'), + ftlFileName: 'auth.ftl', + }, + }) ); Container.set(FxaMailer, fxaMailer); diff --git a/packages/fxa-auth-server/scripts/recorded-future/check-and-reset.ts b/packages/fxa-auth-server/scripts/recorded-future/check-and-reset.ts index 0363ced507d..1ea89fdd154 100755 --- a/packages/fxa-auth-server/scripts/recorded-future/check-and-reset.ts +++ b/packages/fxa-auth-server/scripts/recorded-future/check-and-reset.ts @@ -23,6 +23,7 @@ import crypto from 'crypto'; import { promisify } from 'util'; +import { join } from 'path'; import { Command } from 'commander'; import { Container } from 'typedi'; @@ -53,6 +54,13 @@ import { } from './lib'; import { AppConfig } from '../../lib/types'; import { AccountEventsManager } from '../../lib/account-events'; +import { FxaMailer } from '../../lib/senders/fxa-mailer'; +import { FxaMailerFormat } from '../../lib/senders/fxa-mailer-format'; +import { + EmailLinkBuilder, + NodeRendererBindings, +} from '@fxa/accounts/email-renderer'; +import { EmailSender, Bounces } from '@fxa/accounts/email-sender'; type ResetableAccount = NonNullable< Awaited>> @@ -292,6 +300,31 @@ async function resetAccounts( const mailer: any = senders.email; const accountEventManager = new AccountEventsManager(); + // setup for fxa-mailer, since this runs outside of the key_server context we + // have to do a bit of manual setup + const bounce = new Bounces(config.smtp.bounces, { + // libs expectation for db is a bit simpler so we just pass through the + // existing function + emailBounces: { findByEmail: (email) => authDb.emailBounces(email) }, + }); + const emailSender = new EmailSender(config.smtp, bounce, statsd, log); + const linkBuilderConfig = { + baseUri: config.contentServer.url, + ...config.smtp, + }; + const linkBuilder = new EmailLinkBuilder(linkBuilderConfig); + const fxaMailer = new FxaMailer( + emailSender, + linkBuilder, + config.smtp, + new NodeRendererBindings({ + translations: { + basePath: join(__dirname, '../../public/locales'), + ftlFileName: 'auth.ftl', + }, + }) + ); + for (const acct of accountsToReset) { try { await authDb.resetAccount( @@ -306,7 +339,41 @@ async function resetAccounts( } ); await oauthDb.removeTokensAndCodes(acct.uid); - await mailer.sendPasswordChangeRequiredEmail(acct.emails, acct); + if (fxaMailer.canSend('passwordChangeRequired')) { + // the new fxa-mailer is more type script, so we create a 'fake' + // request to satisfy the type checking + const request = { + app: { + ua: {}, + metricsContext: Promise.resolve({ + flowBeginTime: 1234567890, + flowId: 'fxa-internal-flow-id', + }), + geo: { + timeZone: '', + location: { + city: '', + state: '', + stateCode: '', + country: '', + countryCode: '', + postalCode: '', + }, + }, + acceptLanguage: 'en/US', + }, + }; + await fxaMailer.sendPasswordChangeRequiredEmail({ + ...FxaMailerFormat.account(acct as unknown as any), // this _should_ be safe + ...(await FxaMailerFormat.metricsContext(request)), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + await mailer.sendPasswordChangeRequiredEmail(acct.emails, acct); + } await accountEventManager.recordSecurityEvent(authDb, { uid: acct.uid, name: 'account.must_reset', diff --git a/packages/fxa-auth-server/test/local/ip_profiling.js b/packages/fxa-auth-server/test/local/ip_profiling.js index 7ea07cc2a33..468d2dd5fdf 100644 --- a/packages/fxa-auth-server/test/local/ip_profiling.js +++ b/packages/fxa-auth-server/test/local/ip_profiling.js @@ -150,7 +150,7 @@ describe('IP Profiling', function () { return runTest(route, mockRequest, (response) => { assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called' ); @@ -172,7 +172,7 @@ describe('IP Profiling', function () { return runTest(route, mockRequest, (response) => { assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 0, 'mailer.sendVerifyLoginEmail was not called' ); @@ -194,7 +194,7 @@ describe('IP Profiling', function () { return runTest(route, mockRequest, (response) => { assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called' ); @@ -230,7 +230,7 @@ describe('IP Profiling', function () { return runTest(route, mockRequest, (response) => { assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called' ); @@ -239,7 +239,7 @@ describe('IP Profiling', function () { return runTest(route, mockRequest); }).then((response) => { assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 2, 'mailer.sendVerifyLoginEmail was called' ); @@ -254,7 +254,7 @@ describe('IP Profiling', function () { return runTest(route, mockRequest, (response) => { assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called' ); @@ -263,7 +263,7 @@ describe('IP Profiling', function () { return runTest(route, mockRequest); }).then((response) => { assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 2, 'mailer.sendVerifyLoginEmail was called' ); diff --git a/packages/fxa-auth-server/test/local/routes/account.js b/packages/fxa-auth-server/test/local/routes/account.js index 60881deb706..a9854fd64ea 100644 --- a/packages/fxa-auth-server/test/local/routes/account.js +++ b/packages/fxa-auth-server/test/local/routes/account.js @@ -966,6 +966,7 @@ describe('/account/create', () => { } ); const mockMailer = mocks.mockMailer(); + const mockFxaMailer = mocks.mockFxaMailer(); const mockPush = mocks.mockPush(); const verificationReminders = mocks.mockVerificationReminders(); const subscriptionAccountReminders = mocks.mockVerificationReminders(); @@ -1006,6 +1007,7 @@ describe('/account/create', () => { uid, verificationReminders, subscriptionAccountReminders, + mockFxaMailer, }; } @@ -1016,13 +1018,13 @@ describe('/account/create', () => { keyFetchTokenId, mockDB, mockLog, - mockMailer, mockMetricsContext, mockRequest, route, sessionTokenId, uid, verificationReminders, + mockFxaMailer, } = setup(); const now = Date.now(); @@ -1278,31 +1280,29 @@ describe('/account/create', () => { assert.equal(securityEvent.ipAddr, clientAddress); assert.equal( - mockMailer.sendVerifyEmail.callCount, + mockFxaMailer.sendVerifyEmail.callCount, 1, - 'mailer.sendVerifyEmail was called' - ); - args = mockMailer.sendVerifyEmail.args[0]; - assert.equal(args[2].location.city, 'Mountain View'); - assert.equal(args[2].location.country, 'United States'); - assert.equal(args[2].acceptLanguage, 'en-US'); - assert.equal(args[2].timeZone, 'America/Los_Angeles'); - assert.equal(args[2].uaBrowser, 'Firefox Mobile'); - assert.equal(args[2].uaBrowserVersion, '9'); - assert.equal(args[2].uaOS, 'iOS'); - assert.equal(args[2].uaOSVersion, '11'); - assert.strictEqual(args[2].uaDeviceType, 'tablet'); + 'mockFxaMailer.sendVerifyEmail was not called' + ); + args = mockFxaMailer.sendVerifyEmail.args[0]; + assert.equal(args[0].location.city, 'Mountain View'); + assert.equal(args[0].location.country, 'United States'); + assert.equal(args[0].acceptLanguage, 'en-US'); + assert.equal(args[0].timeZone, 'America/Los_Angeles'); + assert.equal(args[0].device.uaBrowser, 'Firefox Mobile'); + assert.equal(args[0].device.uaOS, 'iOS'); + assert.equal(args[0].device.uaOSVersion, '11'); assert.equal( - args[2].deviceId, + args[0].deviceId, mockRequest.payload.metricsContext.deviceId ); - assert.equal(args[2].flowId, mockRequest.payload.metricsContext.flowId); + assert.equal(args[0].flowId, mockRequest.payload.metricsContext.flowId); assert.equal( - args[2].flowBeginTime, + args[0].flowBeginTime, mockRequest.payload.metricsContext.flowBeginTime ); - assert.equal(args[2].service, 'sync'); - assert.equal(args[2].uid, uid); + assert.equal(args[0].sync, true); + assert.equal(args[0].uid, uid); assert.equal(verificationReminders.create.callCount, 1); args = verificationReminders.create.args[0]; @@ -1337,11 +1337,11 @@ describe('/account/create', () => { it('should create a non-sync account', () => { const { mockLog, - mockMailer, mockRequest, route, uid, verificationReminders, + mockFxaMailer, } = setup(); const now = Date.now(); @@ -1393,12 +1393,12 @@ describe('/account/create', () => { ); assert.equal( - mockMailer.sendVerifyEmail.callCount, + mockFxaMailer.sendVerifyEmail.callCount, 1, - 'mailer.sendVerifyEmail was called' + 'mockFxaMailer.sendVerifyEmail was not called' ); - args = mockMailer.sendVerifyEmail.args[0]; - assert.equal(args[2].service, 'foo'); + args = mockFxaMailer.sendVerifyEmail.args[0]; + assert.equal(args[0].sync, false); sinon.assert.calledOnce(glean.registration.confirmationEmailSent); @@ -1456,9 +1456,10 @@ describe('/account/create', () => { }); it('should return an error if email fails to send', () => { - const { mockMailer, mockRequest, route, verificationReminders } = setup(); + const { mockRequest, route, verificationReminders, mockFxaMailer } = + setup(); - mockMailer.sendVerifyEmail = sinon.spy(() => Promise.reject()); + mockFxaMailer.sendVerifyEmail = sinon.spy(() => Promise.reject()); return runTest(route, mockRequest).then(assert.fail, (err) => { assert.equal(err.message, 'Failed to send email'); @@ -1471,9 +1472,10 @@ describe('/account/create', () => { }); it('should return a bounce error if send fails with one', () => { - const { mockMailer, mockRequest, route, verificationReminders } = setup(); + const { mockRequest, route, verificationReminders, mockFxaMailer } = + setup(); - mockMailer.sendVerifyEmail = sinon.spy(() => + mockFxaMailer.sendVerifyEmail = sinon.spy(() => Promise.reject(error.emailBouncedHard(42)) ); @@ -2424,7 +2426,10 @@ describe('/account/login', () => { mockMailer.sendVerifyLoginCodeEmail = sinon.spy(() => Promise.resolve()); mockMailer.sendVerifyShortCodeEmail = sinon.spy(() => Promise.resolve()); mockMailer.sendVerifyEmail.resetHistory(); - mockFxaMailer.sendNewDeviceLoginEmail.resetHistory(); + // some tests change what these resolve (or reject) to, so we completely reset + mockFxaMailer.sendNewDeviceLoginEmail = sinon.stub().resolves(); + mockFxaMailer.sendVerifyEmail = sinon.stub().resolves(); + mockFxaMailer.sendVerifyLoginEmail = sinon.stub().resolves(); mockDB.createSessionToken.resetHistory(); mockDB.sessions.resetHistory(); mockMetricsContext.stash.resetHistory(); @@ -2691,33 +2696,31 @@ describe('/account/login', () => { assert.equal(args[1], 'login', 'second argument was flow type'); assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called' ); - args = mockMailer.sendVerifyLoginEmail.args[0]; - assert.equal(args[2].acceptLanguage, 'en-US'); - assert.equal(args[2].location.city, 'Mountain View'); - assert.equal(args[2].location.country, 'United States'); - assert.equal(args[2].timeZone, 'America/Los_Angeles'); - assert.equal(args[2].uaBrowser, 'Firefox'); - assert.equal(args[2].uaBrowserVersion, '50'); - assert.equal(args[2].uaOS, 'Android'); - assert.equal(args[2].uaOSVersion, '6'); - assert.equal(args[2].uaDeviceType, 'mobile'); + args = mockFxaMailer.sendVerifyLoginEmail.args[0]; + assert.equal(args[0].acceptLanguage, 'en-US'); + assert.equal(args[0].location.city, 'Mountain View'); + assert.equal(args[0].location.country, 'United States'); + assert.equal(args[0].timeZone, 'America/Los_Angeles'); + assert.equal(args[0].device.uaBrowser, 'Firefox'); + assert.equal(args[0].device.uaOS, 'Android'); + assert.equal(args[0].device.uaOSVersion, '6'); assert.equal( - args[2].deviceId, + args[0].deviceId, mockRequest.payload.metricsContext.deviceId ); - assert.equal(args[2].flowId, mockRequest.payload.metricsContext.flowId); + assert.equal(args[0].flowId, mockRequest.payload.metricsContext.flowId); assert.equal( - args[2].flowBeginTime, + args[0].flowBeginTime, mockRequest.payload.metricsContext.flowBeginTime ); - assert.equal(args[2].service, 'sync'); - assert.equal(args[2].uid, uid); + assert.equal(args[0].sync, true); + assert.equal(args[0].uid, uid); - assert.equal(mockMailer.sendNewDeviceLoginEmail.callCount, 0); + assert.equal(mockFxaMailer.sendNewDeviceLoginEmail.callCount, 0); assert.ok( !response.verified, 'response indicates account is not verified' @@ -2762,15 +2765,15 @@ describe('/account/login', () => { return runTest(route, mockRequest, (response) => { assert.equal( - mockMailer.sendVerifyEmail.callCount, + mockFxaMailer.sendVerifyEmail.callCount, 1, - 'mailer.sendVerifyEmail was called' + 'mockFxaMailer.sendVerifyEmail was not called' ); // Verify that the email code was sent - const verifyCallArgs = mockMailer.sendVerifyEmail.getCall(0).args; + const verifyCallArgs = mockFxaMailer.sendVerifyEmail.getCall(0).args; assert.notEqual( - verifyCallArgs[1], + verifyCallArgs[0].code, emailCode, 'mailer.sendVerifyEmail was called with a fresh verification code' ); @@ -2866,7 +2869,7 @@ describe('/account/login', () => { assert.equal( mockMailer.sendVerifyEmail.callCount, 0, - 'mailer.sendVerifyEmail was not called' + 'mockMailer.sendVerifyEmail was called' ); assert.equal(mockMailer.sendNewDeviceLoginEmail.callCount, 0); assert.ok( @@ -2885,24 +2888,25 @@ describe('/account/login', () => { ); assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called' ); + const args = mockFxaMailer.sendVerifyLoginEmail.getCall(0).args[0]; assert.equal( - mockMailer.sendVerifyLoginEmail.getCall(0).args[2].acceptLanguage, + args.acceptLanguage, 'en-US' ); assert.equal( - mockMailer.sendVerifyLoginEmail.getCall(0).args[2].location.city, + args.location.city, 'Mountain View' ); assert.equal( - mockMailer.sendVerifyLoginEmail.getCall(0).args[2].location.country, + args.location.country, 'United States' ); assert.equal( - mockMailer.sendVerifyLoginEmail.getCall(0).args[2].timeZone, + args.timeZone, 'America/Los_Angeles' ); }); @@ -2945,7 +2949,7 @@ describe('/account/login', () => { ); assert.equal(mockMailer.sendNewDeviceLoginEmail.callCount, 0); assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called' ); @@ -3231,15 +3235,15 @@ describe('/account/login', () => { 'sessionToken was created unverified' ); assert.equal( - mockMailer.sendVerifyEmail.callCount, + mockFxaMailer.sendVerifyEmail.callCount, 1, - 'mailer.sendVerifyEmail was called' + 'mockFxaMailer.sendVerifyEmail was not called' ); assert.equal(mockMailer.sendNewDeviceLoginEmail.callCount, 0); assert.equal( mockMailer.sendVerifyLoginEmail.callCount, 0, - 'mailer.sendVerifyLoginEmail was not called' + 'mailer.sendVerifyLoginEmail was called' ); assert.ok( !response.verified, @@ -3259,7 +3263,7 @@ describe('/account/login', () => { }); it('should return an error if email fails to send', () => { - mockMailer.sendVerifyLoginEmail = sinon.spy(() => Promise.reject()); + mockFxaMailer.sendVerifyLoginEmail = sinon.spy(() => Promise.reject()); return runTest(route, mockRequest).then(assert.fail, (err) => { assert.equal(err.message, 'Failed to send email'); @@ -3336,9 +3340,9 @@ describe('/account/login', () => { 'sessionToken was created unverified' ); assert.equal( - mockMailer.sendVerifyEmail.callCount, + mockFxaMailer.sendVerifyEmail.callCount, 0, - 'mailer.sendVerifyEmail was not called' + 'mockFxaMailer.sendVerifyEmail was called' ); assert.equal(mockMailer.sendNewDeviceLoginEmail.callCount, 0); assert.ok( @@ -3357,24 +3361,25 @@ describe('/account/login', () => { ); assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called' ); + const sendVerifyLoginEmailArgs = mockFxaMailer.sendVerifyLoginEmail.getCall(0).args[0]; assert.equal( - mockMailer.sendVerifyLoginEmail.getCall(0).args[2].acceptLanguage, + sendVerifyLoginEmailArgs.acceptLanguage, 'en-US' ); assert.equal( - mockMailer.sendVerifyLoginEmail.getCall(0).args[2].location.city, + sendVerifyLoginEmailArgs.location.city, 'Mountain View' ); assert.equal( - mockMailer.sendVerifyLoginEmail.getCall(0).args[2].location.country, + sendVerifyLoginEmailArgs.location.country, 'United States' ); assert.equal( - mockMailer.sendVerifyLoginEmail.getCall(0).args[2].timeZone, + sendVerifyLoginEmailArgs.timeZone, 'America/Los_Angeles' ); }); @@ -3398,7 +3403,7 @@ describe('/account/login', () => { assert.equal( mockMailer.sendVerifyEmail.callCount, 0, - 'mailer.sendVerifyEmail was not called' + 'mailer.sendVerifyEmail was called' ); assert.equal(mockFxaMailer.sendNewDeviceLoginEmail.callCount, 1); assert.ok( @@ -3432,7 +3437,7 @@ describe('/account/login', () => { 'sessionToken was created verified' ); assert.equal( - mockMailer.sendVerifyEmail.callCount, + mockFxaMailer.sendVerifyEmail.callCount, 0, 'mailer.sendVerifyEmail was not called' ); @@ -3479,11 +3484,11 @@ describe('/account/login', () => { 'sessionToken was created unverified' ); assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, + mockFxaMailer.sendVerifyLoginEmail.callCount, 1, 'mailer.sendVerifyLoginEmail was called' ); - assert.equal(mockMailer.sendNewDeviceLoginEmail.callCount, 0); + assert.equal(mockFxaMailer.sendNewDeviceLoginEmail.callCount, 0); assert.ok( !response.verified, 'response indicates account is unverified' @@ -3634,8 +3639,8 @@ describe('/account/login', () => { 'sessionToken was created unverified' ); assert.equal( - mockMailer.sendVerifyLoginEmail.callCount, - 1, // TODO + mockFxaMailer.sendVerifyLoginEmail.callCount, + 1, 'mailer.sendVerifyLoginEmail was called' ); assert.equal(mockFxaMailer.sendNewDeviceLoginEmail.callCount, 0); diff --git a/packages/fxa-auth-server/test/local/routes/emails.js b/packages/fxa-auth-server/test/local/routes/emails.js index 8e23af99aed..8c3d3f76302 100644 --- a/packages/fxa-auth-server/test/local/routes/emails.js +++ b/packages/fxa-auth-server/test/local/routes/emails.js @@ -571,12 +571,16 @@ describe('/recovery_email/status', () => { describe('/recovery_email/resend_code', () => { const config = {}; const secondEmailCode = crypto.randomBytes(16); - const mockDB = mocks.mockDB({ secondEmailCode: secondEmailCode }); + const mockDB = mocks.mockDB({ + secondEmailCode: secondEmailCode, + email: TEST_EMAIL, + }); const mockLog = mocks.mockLog(); mockLog.flowEvent = sinon.spy(() => { return Promise.resolve(); }); const mockMailer = mocks.mockMailer(); + const fxaMailer = mocks.mockFxaMailer(); const mockMetricsContext = mocks.mockMetricsContext(); const accountRoutes = makeRoutes({ config: config, @@ -590,6 +594,10 @@ describe('/recovery_email/resend_code', () => { const mockRequest = mocks.mockRequest({ log: mockLog, metricsContext: mockMetricsContext, + uaBrowser: 'Firefox', + uaBrowserVersion: 52, + uaOS: 'Mac OS X', + uaOSVersion: '10.10', credentials: { uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'), deviceId: 'wibble', @@ -605,6 +613,7 @@ describe('/recovery_email/resend_code', () => { payload: { service: 'sync', metricsContext: { + deviceId: 'wibble', flowBeginTime: Date.now(), flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103', @@ -620,39 +629,42 @@ describe('/recovery_email/resend_code', () => { 'email.verification.resent' ); - assert.equal(mockMailer.sendVerifyEmail.callCount, 1); - const args = mockMailer.sendVerifyEmail.args[0]; - assert.equal(args[2].uaBrowser, 'Firefox'); - assert.equal(args[2].uaBrowserVersion, '52'); - assert.equal(args[2].uaOS, 'Mac OS X'); - assert.equal(args[2].uaOSVersion, '10.10'); - assert.ok(knownIpLocation.location.city.has(args[2].location.city)); - assert.equal(args[2].location.country, knownIpLocation.location.country); - assert.equal(args[2].ip, knownIpLocation.ip); - assert.equal(args[2].timeZone, knownIpLocation.location.tz); - assert.strictEqual(args[2].uaDeviceType, undefined); - assert.equal(args[2].deviceId, mockRequest.auth.credentials.deviceId); - assert.equal(args[2].flowId, mockRequest.payload.metricsContext.flowId); + assert.equal(fxaMailer.sendVerifyEmail.callCount, 1); + const args = fxaMailer.sendVerifyEmail.args[0]; + assert.equal(args[0].device.uaBrowser, 'Firefox'); + assert.equal(args[0].device.uaOS, 'Mac OS X'); + assert.equal(args[0].device.uaOSVersion, '10.10'); + assert.ok(knownIpLocation.location.city.has(args[0].location.city)); + assert.equal(args[0].location.country, knownIpLocation.location.country); + assert.equal(args[0].timeZone, 'America/Los_Angeles'); + assert.equal(args[0].deviceId, 'wibble'); + assert.equal(args[0].flowId, mockRequest.payload.metricsContext.flowId); assert.equal( - args[2].flowBeginTime, + args[0].flowBeginTime, mockRequest.payload.metricsContext.flowBeginTime ); - assert.equal(args[2].service, mockRequest.payload.service); - assert.equal(args[2].uid, mockRequest.auth.credentials.uid); - assert.equal(args[2].style, 'trailhead'); + assert.equal(args[0].sync, mockRequest.payload.service === 'sync'); + assert.equal(args[0].uid, mockRequest.auth.credentials.uid); + assert.equal(args[0].resume, mockRequest.payload.resume); }).then(() => { - mockMailer.sendVerifyEmail.resetHistory(); + fxaMailer.sendVerifyEmail.resetHistory(); mockLog.flowEvent.resetHistory(); }); }); it('confirmation', () => { + const deviceId = uuid.v4({}, Buffer.alloc(16)).toString('hex'); const mockRequest = mocks.mockRequest({ log: mockLog, metricsContext: mockMetricsContext, + uaBrowser: 'Firefox', + uaBrowserVersion: '50', + uaOS: 'Android', + uaOSVersion: '6', + uaDeviceType: 'tablet', credentials: { uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'), - deviceId: uuid.v4({}, Buffer.alloc(16)).toString('hex'), + deviceId: deviceId, email: TEST_EMAIL, emailVerified: true, tokenVerified: false, @@ -666,6 +678,7 @@ describe('/recovery_email/resend_code', () => { payload: { service: 'foo', metricsContext: { + deviceId: deviceId, flowBeginTime: Date.now(), flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103', @@ -681,21 +694,20 @@ describe('/recovery_email/resend_code', () => { 'email.confirmation.resent' ); - assert.equal(mockMailer.sendVerifyLoginEmail.callCount, 1); - const args = mockMailer.sendVerifyLoginEmail.args[0]; - assert.equal(args[2].uaBrowser, 'Firefox'); - assert.equal(args[2].uaBrowserVersion, '50'); - assert.equal(args[2].uaOS, 'Android'); - assert.equal(args[2].uaOSVersion, '6'); - assert.strictEqual(args[2].uaDeviceType, 'tablet'); - assert.equal(args[2].deviceId, mockRequest.auth.credentials.deviceId); - assert.equal(args[2].flowId, mockRequest.payload.metricsContext.flowId); + assert.equal(fxaMailer.sendVerifyLoginEmail.callCount, 1); + const args = fxaMailer.sendVerifyLoginEmail.args[0]; + assert.equal(args[0].device.uaBrowser, 'Firefox'); + assert.equal(args[0].device.uaOS, 'Android'); + assert.equal(args[0].device.uaOSVersion, '6'); + assert.equal(args[0].deviceId, mockRequest.auth.credentials.deviceId); + assert.equal(args[0].flowId, mockRequest.payload.metricsContext.flowId); assert.equal( - args[2].flowBeginTime, + args[0].flowBeginTime, mockRequest.payload.metricsContext.flowBeginTime ); - assert.equal(args[2].service, mockRequest.payload.service); - assert.equal(args[2].uid, mockRequest.auth.credentials.uid); + assert.equal(args[0].sync, mockRequest.payload.service === 'sync'); + assert.equal(args[0].uid, mockRequest.auth.credentials.uid); + assert.equal(args[0].clientName, 'Firefox'); }); }); }); @@ -1182,12 +1194,23 @@ describe('/recovery_email', () => { }); describe('/mfa/recovery_email/secondary/resend_code', () => { + let fxaMailer; + beforeEach(() => { + fxaMailer = mocks.mockFxaMailer(); + }); + afterEach(() => { + fxaMailer.sendVerifySecondaryCodeEmail.resetHistory(); + }); it('resends code when redis reservation exists for this uid', async () => { const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex'); const email = TEST_EMAIL_ADDITIONAL; - const normalized = normalizeEmail(email); const mockLog = mocks.mockLog(); const mockMailer = mocks.mockMailer(); + const mockDB = mocks.mockDB({ + uid, + email: TEST_EMAIL, + emailVerified: true, + }); const secret = 'abcd1234abcd1234abcd1234abcd1234'; const authServerCacheRedis = { @@ -1201,6 +1224,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { authServerCacheRedis, mailer: mockMailer, log: mockLog, + db: mockDB, }, {} ); @@ -1224,10 +1248,10 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { const response = await runTest(route, request); assert.ok(response); - assert.calledOnce(mockMailer.sendVerifySecondaryCodeEmail); - const args = mockMailer.sendVerifySecondaryCodeEmail.args[0]; - assert.equal(args[0][0].normalizedEmail, normalized); - assert.equal(args[2].code, expectedCode, 'verification codes match'); + assert.calledOnce(fxaMailer.sendVerifySecondaryCodeEmail); + const args = fxaMailer.sendVerifySecondaryCodeEmail.args[0]; + assert.equal(args[0].email, email); + assert.equal(args[0].code, expectedCode, 'verification codes match'); }); it('recreates reservation when expired and resends code', async () => { @@ -1273,7 +1297,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { assert.equal(setArgs[2], 'EX'); // Expiration flag assert.equal(setArgs[4], 'NX'); // Only set if not exists // Verify email was sent - assert.calledOnce(mockMailer.sendVerifySecondaryCodeEmail); + assert.calledOnce(fxaMailer.sendVerifySecondaryCodeEmail); assert.calledOnce(mockLog.info); assert.equal( mockLog.info.args[0][0], @@ -1287,6 +1311,11 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { const otherUid = uuid.v4({}, Buffer.alloc(16)).toString('hex'); const email = TEST_EMAIL_ADDITIONAL; const mockMailer = mocks.mockMailer(); + const mockDB = mocks.mockDB({ + uid, + email: TEST_EMAIL, + emailVerified: true, + }); const authServerCacheRedis = { get: sinon .stub() @@ -1294,7 +1323,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { set: sinon.stub().resolves('OK'), del: sinon.stub().resolves(1), }; - const routes = makeRoutes({ authServerCacheRedis, mailer: mockMailer }, {}); + const routes = makeRoutes({ authServerCacheRedis, mailer: mockMailer, db: mockDB }, {}); const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { uid, email: TEST_EMAIL }, @@ -1347,7 +1376,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { // Verify new reservation was created assert.calledOnce(authServerCacheRedis.set); // Verify email was sent - assert.calledOnce(mockMailer.sendVerifySecondaryCodeEmail); + assert.calledOnce(fxaMailer.sendVerifySecondaryCodeEmail); // Verify recreation was logged with correct reason assert.calledWith(mockLog.info, 'secondary_email.reservation_recreated', { uid, @@ -1481,6 +1510,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { const mockMailer = mocks.mockMailer(); const mockLog = mocks.mockLog(); const mockDB = mocks.mockDB({ + uid, email: TEST_EMAIL, emailVerified: true, }); @@ -1488,9 +1518,9 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { errno: error.ERRNO.SECONDARY_EMAIL_UNKNOWN, }); // Simulate email send failure - mockMailer.sendVerifySecondaryCodeEmail = sinon - .stub() - .rejects(new Error('Email service unavailable')); + fxaMailer.sendVerifySecondaryCodeEmail.rejects( + new Error('Email service unavailable') + ); const authServerCacheRedis = { get: sinon.stub().resolves(null), // No existing reservation set: sinon.stub().resolves('OK'), @@ -1531,10 +1561,15 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { const secret = 'existingsecret1234567890123456'; const mockMailer = mocks.mockMailer(); const mockLog = mocks.mockLog(); + const mockDB = mocks.mockDB({ + uid, + email: TEST_EMAIL, + emailVerified: true, + }); // Simulate email send failure - mockMailer.sendVerifySecondaryCodeEmail = sinon - .stub() - .rejects(new Error('Email service unavailable')); + fxaMailer.sendVerifySecondaryCodeEmail.rejects( + new Error('Email service unavailable') + ); const authServerCacheRedis = { get: sinon.stub().resolves(JSON.stringify({ uid, secret })), // Existing reservation set: sinon.stub().resolves('OK'), @@ -1545,6 +1580,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { authServerCacheRedis, mailer: mockMailer, log: mockLog, + db: mockDB, }, {} ); diff --git a/packages/fxa-auth-server/test/local/routes/mfa.js b/packages/fxa-auth-server/test/local/routes/mfa.js index 3f203ed87a3..c7dfe5d51e8 100644 --- a/packages/fxa-auth-server/test/local/routes/mfa.js +++ b/packages/fxa-auth-server/test/local/routes/mfa.js @@ -96,6 +96,7 @@ describe('mfa', () => { log = mocks.mockLog(); customs = mocks.mockCustoms(); mailer = mocks.mockMailer(); + const fxaMailer = mocks.mockFxaMailer(); statsd = mocks.mockStatsd(); db = mocks.mockDB({ uid: UID, @@ -124,6 +125,11 @@ describe('mfa', () => { code = data.code; } ); + fxaMailer.sendVerifyAccountChangeEmail = sandbox.spy( + (data) => { + code = data.code; + } + ); }); afterEach(() => { diff --git a/packages/fxa-auth-server/test/local/routes/password.js b/packages/fxa-auth-server/test/local/routes/password.js index 8258b02eea0..4aca4560a2f 100644 --- a/packages/fxa-auth-server/test/local/routes/password.js +++ b/packages/fxa-auth-server/test/local/routes/password.js @@ -550,12 +550,10 @@ describe('/password', () => { assert.equal(args[1].id, accountResetToken.id); assert.equal(args[1].uid, uid); - assert.equal(mockMailer.sendPasswordResetEmail.callCount, 1); - assert.equal(mockMailer.sendPasswordResetEmail.args[0][2].uid, uid); - assert.equal( - mockMailer.sendPasswordResetEmail.args[0][2].deviceId, - 'wibble' - ); + assert.equal(mockFxaMailer.sendPasswordResetEmail.callCount, 1); + const passwordResetArgs = mockFxaMailer.sendPasswordResetEmail.args[0]; + assert.equal(passwordResetArgs[0].uid, uid); + assert.equal(passwordResetArgs[0].deviceId, 'wibble'); }); }); @@ -760,14 +758,13 @@ describe('/password', () => { ); assert.equal(mockDB.account.callCount, 1); - assert.equal(mockMailer.sendPasswordChangedEmail.callCount, 1); - let args = mockMailer.sendPasswordChangedEmail.args[0]; - assert.lengthOf(args, 3); - assert.equal(args[1].email, TEST_EMAIL); - assert.equal(args[2].location.city, 'Mountain View'); - assert.equal(args[2].location.country, 'United States'); - assert.equal(args[2].timeZone, 'America/Los_Angeles'); - assert.equal(args[2].uid, uid); + assert.equal(mockFxaMailer.sendPasswordChangedEmail.callCount, 1); + let args = mockFxaMailer.sendPasswordChangedEmail.args[0]; + assert.equal(args[0].to, TEST_EMAIL); + assert.equal(args[0].location.city, 'Mountain View'); + assert.equal(args[0].location.country, 'United States'); + assert.equal(args[0].timeZone, 'America/Los_Angeles'); + assert.equal(args[0].uid, uid); assert.equal( mockLog.activityEvent.callCount, @@ -874,6 +871,10 @@ describe('/password', () => { }), }; const mockLog = mocks.mockLog(); + + // Configure mockFxaMailer to reject for this test + mockFxaMailer.sendPasswordChangedEmail.rejects(error.emailBouncedHard()); + const mockRequest = mocks.mockRequest({ credentials: { uid: uid, @@ -935,7 +936,7 @@ describe('/password', () => { ); assert.equal(mockDB.account.callCount, 1); - assert.equal(mockMailer.sendPasswordChangedEmail.callCount, 1); + assert.equal(mockFxaMailer.sendPasswordChangedEmail.callCount, 1); assert.equal( mockLog.activityEvent.callCount, @@ -1218,7 +1219,7 @@ describe('/password', () => { // Verify notifications sinon.assert.calledOnce(mockPush.notifyPasswordChanged); - sinon.assert.calledOnce(mockMailer.sendPasswordChangedEmail); + sinon.assert.calledOnce(mockFxaMailer.sendPasswordChangedEmail); // Verify security events sinon.assert.calledWith( diff --git a/packages/fxa-auth-server/test/local/routes/unblock-codes.js b/packages/fxa-auth-server/test/local/routes/unblock-codes.js index f7d458b4327..0a4aa6fb535 100644 --- a/packages/fxa-auth-server/test/local/routes/unblock-codes.js +++ b/packages/fxa-auth-server/test/local/routes/unblock-codes.js @@ -49,6 +49,7 @@ describe('/account/login/send_unblock_code', () => { }, }); const mockMailer = mocks.mockMailer(); + const mockFxaMailer = mocks.mockFxaMailer(); const mockDb = mocks.mockDB({ uid: uid, email: email, @@ -67,7 +68,7 @@ describe('/account/login/send_unblock_code', () => { afterEach(() => { mockDb.accountRecord.resetHistory(); mockDb.createUnblockCode.resetHistory(); - mockMailer.sendUnblockCodeEmail.resetHistory(); + mockFxaMailer.sendUnblockCodeEmail.resetHistory(); }); it('signin unblock enabled', () => { @@ -91,9 +92,9 @@ describe('/account/login/send_unblock_code', () => { assert.equal(dbArgs.length, 1); assert.equal(dbArgs[0], uid); - assert.equal(mockMailer.sendUnblockCodeEmail.callCount, 1); - const args = mockMailer.sendUnblockCodeEmail.args[0]; - assert.equal(args.length, 3); + assert.equal(mockFxaMailer.sendUnblockCodeEmail.callCount, 1); + const args = mockFxaMailer.sendUnblockCodeEmail.args[0]; + assert.equal(args.length, 1); assert.equal( mockLog.flowEvent.callCount, @@ -127,7 +128,7 @@ describe('/account/login/send_unblock_code', () => { 1, 'db.createUnblockCode called' ); - assert.equal(mockMailer.sendUnblockCodeEmail.callCount, 1); + assert.equal(mockFxaMailer.sendUnblockCodeEmail.callCount, 1); }); }); }); diff --git a/packages/fxa-auth-server/test/local/routes/utils/signin.js b/packages/fxa-auth-server/test/local/routes/utils/signin.js index f72663d2340..d6a77848417 100644 --- a/packages/fxa-auth-server/test/local/routes/utils/signin.js +++ b/packages/fxa-auth-server/test/local/routes/utils/signin.js @@ -735,11 +735,13 @@ describe('sendSigninNotifications', () => { config, log, mailer, + fxaMailer, metricsContext, request, accountRecord, sessionToken, - sendSigninNotifications; + sendSigninNotifications, + clock; const defaultMockRequestData = (log, metricsContext) => ({ log, metricsContext, @@ -778,9 +780,13 @@ describe('sendSigninNotifications', () => { }); beforeEach(() => { + // Freeze time at a specific timestamp for consistent test assertions + clock = sinon.useFakeTimers(1769555935958); + db = mocks.mockDB(); log = mocks.mockLog(); mailer = mocks.mockMailer(); + fxaMailer = mocks.mockFxaMailer(); metricsContext = mocks.mockMetricsContext(); request = mocks.mockRequest(defaultMockRequestData(log, metricsContext)); accountRecord = { @@ -812,6 +818,12 @@ describe('sendSigninNotifications', () => { }).sendSigninNotifications; }); + afterEach(() => { + if (clock) { + clock.restore(); + } + }); + after(() => { Container.reset(); }); @@ -867,9 +879,9 @@ describe('sendSigninNotifications', () => { countryCode: 'US', }); - assert.notCalled(mailer.sendVerifyEmail); - assert.notCalled(mailer.sendVerifyLoginEmail); - assert.notCalled(mailer.sendVerifyLoginCodeEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyLoginCodeEmail); assert.calledOnce(db.securityEvent); assert.calledWithExactly(db.securityEvent, { @@ -913,23 +925,37 @@ describe('sendSigninNotifications', () => { assert.calledOnce(metricsContext.stash); - assert.calledOnce(mailer.sendVerifyEmail); - assert.calledWithExactly(mailer.sendVerifyEmail, [], accountRecord, { + assert.calledOnce(fxaMailer.sendVerifyEmail); + console.debug('mailer args', fxaMailer.sendVerifyEmail.args[0]); + assert.calledWithExactly(fxaMailer.sendVerifyEmail, { + to: 'test@example.com', + cc: [], + metricsEnabled: true, + uid: 'thisisauid', + deviceId: 'wibble', + flowId: + 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103', + flowBeginTime: 1769555935958, + entrypoint: undefined, + sync: false, acceptLanguage: 'en-US', + date: 'Tuesday, Jan 27, 2026', + time: '3:18:55 PM (PST)', + timeZone: 'America/Los_Angeles', + location: { + stateCode: 'CA', + country: 'United States', + city: 'Mountain View', + }, + device: { + uaBrowser: 'Firefox Mobile', + uaOS: 'iOS', + uaOSVersion: '11', + }, code: 'emailVerifyCode', - deviceId: request.payload.metricsContext.deviceId, - flowBeginTime: request.payload.metricsContext.flowBeginTime, - flowId: request.payload.metricsContext.flowId, + resume: 'myResumeToken', service: undefined, - redirectTo: request.payload.redirectTo, - resume: request.payload.resume, - timeZone: 'America/Los_Angeles', - uid: TEST_UID, - uaBrowser: 'Firefox Mobile', - uaBrowserVersion: '9', - uaOS: 'iOS', - uaOSVersion: '11', - uaDeviceType: 'tablet', + redirectTo: 'redirectMeTo', }); assert.calledThrice(log.flowEvent); @@ -969,23 +995,36 @@ describe('sendSigninNotifications', () => { id: 'tokenVerifyCode', }); - assert.calledOnce(mailer.sendVerifyEmail); - assert.calledWithExactly(mailer.sendVerifyEmail, [], accountRecord, { + assert.calledOnce(fxaMailer.sendVerifyEmail); + assert.calledWithExactly(fxaMailer.sendVerifyEmail, { + to: 'test@example.com', + cc: [], + metricsEnabled: true, + uid: 'thisisauid', + deviceId: 'wibble', + flowId: + 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103', + flowBeginTime: 1769555935958, + entrypoint: undefined, + sync: false, acceptLanguage: 'en-US', - code: 'tokenVerifyCode', // the token verification code is used if available - deviceId: request.payload.metricsContext.deviceId, - flowBeginTime: request.payload.metricsContext.flowBeginTime, - flowId: request.payload.metricsContext.flowId, - service: undefined, - redirectTo: request.payload.redirectTo, - resume: request.payload.resume, + date: 'Tuesday, Jan 27, 2026', + time: '3:18:55 PM (PST)', timeZone: 'America/Los_Angeles', - uid: TEST_UID, - uaBrowser: 'Firefox Mobile', - uaBrowserVersion: '9', - uaOS: 'iOS', - uaOSVersion: '11', - uaDeviceType: 'tablet', + location: { + stateCode: 'CA', + country: 'United States', + city: 'Mountain View', + }, + device: { + uaBrowser: 'Firefox Mobile', + uaOS: 'iOS', + uaOSVersion: '11', + }, + code: 'tokenVerifyCode', + resume: 'myResumeToken', + service: undefined, + redirectTo: 'redirectMeTo', }); assert.calledTwice(log.flowEvent); @@ -1013,7 +1052,7 @@ describe('sendSigninNotifications', () => { assert.calledOnce(db.sessions); assert.calledOnce(log.activityEvent); - assert.notCalled(mailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); assert.notCalled(mailer.sendVerifyLoginCodeEmail); assert.calledOnce(db.securityEvent); @@ -1051,8 +1090,8 @@ describe('sendSigninNotifications', () => { countryCode: 'US', }); - assert.notCalled(mailer.sendVerifyEmail); - assert.notCalled(mailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); assert.notCalled(mailer.sendVerifyLoginCodeEmail); assert.notCalled(mailer.sendNewDeviceLoginEmail); @@ -1083,38 +1122,39 @@ describe('sendSigninNotifications', () => { sessionToken, undefined ).then(() => { - assert.notCalled(mailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); assert.notCalled(mailer.sendVerifyLoginCodeEmail); - assert.calledOnce(mailer.sendVerifyLoginEmail); - assert.calledWithExactly( - mailer.sendVerifyLoginEmail, - accountRecord.emails, - accountRecord, - { - acceptLanguage: 'en-US', - code: 'tokenVerifyCode', - deviceId: request.payload.metricsContext.deviceId, - flowBeginTime: request.payload.metricsContext.flowBeginTime, - flowId: request.payload.metricsContext.flowId, - redirectTo: request.payload.redirectTo, - resume: request.payload.resume, - location: { - city: 'Mountain View', - country: 'United States', - countryCode: 'US', - state: 'California', - stateCode: 'CA', - }, - service: undefined, - timeZone: 'America/Los_Angeles', + assert.calledOnce(fxaMailer.sendVerifyLoginEmail); + assert.calledWithExactly(fxaMailer.sendVerifyLoginEmail, { + to: TEST_EMAIL, + cc: [], + metricsEnabled: true, + uid: TEST_UID, + deviceId: request.payload.metricsContext.deviceId, + flowId: request.payload.metricsContext.flowId, + flowBeginTime: request.payload.metricsContext.flowBeginTime, + entrypoint: undefined, + sync: false, + acceptLanguage: 'en-US', + date: 'Tuesday, Jan 27, 2026', + time: '3:18:55 PM (PST)', + timeZone: 'America/Los_Angeles', + location: { + stateCode: 'CA', + country: 'United States', + city: 'Mountain View', + }, + device: { uaBrowser: 'Firefox Mobile', - uaBrowserVersion: '9', uaOS: 'iOS', uaOSVersion: '11', - uaDeviceType: 'tablet', - uid: TEST_UID, - } - ); + }, + code: 'tokenVerifyCode', + clientName: 'Firefox', + redirectTo: request.payload.redirectTo, + service: undefined, + resume: request.payload.resume, + }); assert.calledTwice(log.flowEvent); assert.calledWithMatch(log.flowEvent.getCall(0), { @@ -1133,9 +1173,9 @@ describe('sendSigninNotifications', () => { sessionToken, 'email' ).then(() => { - assert.notCalled(mailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); assert.notCalled(mailer.sendVerifyLoginCodeEmail); - assert.calledOnce(mailer.sendVerifyLoginEmail); + assert.calledOnce(fxaMailer.sendVerifyLoginEmail); assert.calledTwice(log.flowEvent); assert.calledWithMatch(log.flowEvent.getCall(0), { @@ -1154,8 +1194,8 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-2fa' ).then(() => { - assert.notCalled(mailer.sendVerifyEmail); - assert.notCalled(mailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); assert.calledOnce(mailer.sendVerifyLoginCodeEmail); const expectedCode = otpUtils.generateOtpCode( @@ -1202,8 +1242,8 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-captcha' ).then(() => { - assert.notCalled(mailer.sendVerifyEmail); - assert.notCalled(mailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); assert.notCalled(mailer.sendVerifyLoginCodeEmail); assert.calledOnce(log.flowEvent); @@ -1288,8 +1328,8 @@ describe('sendSigninNotifications', () => { assert.calledOnce(mailer.sendVerifyLoginCodeEmail); const callArgs = mailer.sendVerifyLoginCodeEmail.getCall(0).args[2]; assert.equal(callArgs.service, 'sync'); - assert.notCalled(mailer.sendVerifyEmail); - assert.notCalled(mailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); }); }); @@ -1302,8 +1342,8 @@ describe('sendSigninNotifications', () => { 'email-otp' ).then(() => { assert.calledOnce(mailer.sendVerifyLoginCodeEmail); - assert.notCalled(mailer.sendVerifyEmail); - assert.notCalled(mailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); }); }); @@ -1316,8 +1356,8 @@ describe('sendSigninNotifications', () => { 'email-otp' ).then(() => { assert.notCalled(mailer.sendVerifyLoginCodeEmail); - assert.notCalled(mailer.sendVerifyEmail); - assert.notCalled(mailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); }); }); @@ -1331,8 +1371,8 @@ describe('sendSigninNotifications', () => { true // passwordChangeRequired ).then(() => { assert.calledOnce(mailer.sendVerifyLoginCodeEmail); - assert.notCalled(mailer.sendVerifyEmail); - assert.notCalled(mailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); }); }); }); @@ -1375,8 +1415,8 @@ describe('sendSigninNotifications', () => { return signinUtils .sendSigninNotifications(req, accountRecord, sessionToken, 'email-2fa') .then(() => { - assert.notCalled(mailer.sendVerifyEmail); - assert.notCalled(mailer.sendVerifyLoginEmail); + assert.notCalled(fxaMailer.sendVerifyEmail); + assert.notCalled(fxaMailer.sendVerifyLoginEmail); assert.calledOnce(mailer.sendVerifyLoginCodeEmail); const expectedCode = otpUtils.generateOtpCode( diff --git a/packages/fxa-auth-server/test/mocks.js b/packages/fxa-auth-server/test/mocks.js index 760984f2aab..c179edd0dcf 100644 --- a/packages/fxa-auth-server/test/mocks.js +++ b/packages/fxa-auth-server/test/mocks.js @@ -413,9 +413,10 @@ function mockDB(data, errors) { isPrimary: true, }, ], - uid: data.uid, + uid: uid || data.uid, // Prefer the uid parameter, fall back to data.uid verifierSetAt: data.verifierSetAt ?? Date.now(), wrapWrapKb: data.wrapWrapKb, + metricsOptOutAt: data.metricsOptOutAt || null, }); }), accountEmails: sinon.spy((uid) => { @@ -1160,7 +1161,7 @@ function mockProductConfigurationManager() { function mockFxaMailer(overrides) { const mockFxaMailer = { // add new email methods here! - canSend: sinon.stub().resolves(true), + canSend: sinon.stub().returns(true), sendRecoveryEmail: sinon.stub().resolves(), sendPasswordForgotOtpEmail: sinon.stub().resolves(), sendPostVerifySecondaryEmail: sinon.stub().resolves(), @@ -1190,6 +1191,10 @@ function mockFxaMailer(overrides) { sendVerifySecondaryCodeEmail: sinon.stub().resolves(), sendVerifyLoginEmail: sinon.stub().resolves(), sendVerifyEmail: sinon.stub().resolves(), + sendVerifyAccountChangeEmail: sinon.stub().resolves(), + sendUnblockCodeEmail: sinon.stub().resolves(), + sendPasswordResetEmail: sinon.stub().resolves(), + sendPasswordChangedEmail: sinon.stub().resolves(), ...overrides, }; Container.set(FxaMailer, mockFxaMailer);