From ee8d03d3ba5afa60d8f6be2a155323e15af82398 Mon Sep 17 00:00:00 2001 From: dschom Date: Fri, 23 Jan 2026 14:32:30 -0800 Subject: [PATCH 1/3] wip - wiring up new email sending --- .../src/partials/appBadges/index.ts | 8 +- .../src/renderer/email-link-builder.spec.ts | 7 + .../src/renderer/email-link-builder.ts | 247 ++++- .../src/renderer/fxa-email-renderer.spec.ts | 1 + .../passwordResetAccountRecovery/index.ts | 2 +- .../index.ts | 2 +- .../index.stories.ts | 2 + .../postAddTwoStepAuthentication/index.ts | 2 + packages/fxa-admin-server/src/config/index.ts | 19 + .../fxa-auth-server/lib/routes/account.ts | 129 ++- packages/fxa-auth-server/lib/routes/emails.js | 65 +- .../lib/routes/linked-accounts.ts | 62 +- .../fxa-auth-server/lib/routes/password.ts | 64 +- .../lib/routes/recovery-codes.js | 133 ++- .../lib/routes/recovery-key.js | 163 ++-- .../lib/routes/recovery-phone.ts | 205 ++-- .../fxa-auth-server/lib/routes/session.js | 80 +- packages/fxa-auth-server/lib/routes/totp.js | 245 +++-- .../fxa-auth-server/lib/routes/utils/oauth.js | 34 +- .../lib/senders/fxa-mailer-format.ts | 109 +++ .../lib/senders/fxa-mailer-sanity-check.ts | 361 ++++++++ .../fxa-auth-server/lib/senders/fxa-mailer.ts | 876 ++++++++++++++++-- packages/fxa-auth-server/lib/types.ts | 6 + packages/fxa-shared/lib/user-agent.ts | 48 +- 24 files changed, 2384 insertions(+), 486 deletions(-) create mode 100644 packages/fxa-auth-server/lib/senders/fxa-mailer-format.ts create mode 100644 packages/fxa-auth-server/lib/senders/fxa-mailer-sanity-check.ts diff --git a/libs/accounts/email-renderer/src/partials/appBadges/index.ts b/libs/accounts/email-renderer/src/partials/appBadges/index.ts index 7bf1dede94b..f1de79496d7 100644 --- a/libs/accounts/email-renderer/src/partials/appBadges/index.ts +++ b/libs/accounts/email-renderer/src/partials/appBadges/index.ts @@ -3,10 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ export type TemplateData = { - cssPath: string; - productName: string; - hideDeviceLink: boolean; - onDesktopOrTabletDevice: boolean; + cssPath?: string; // Not passed as template data? + productName?: string; // Not passed as template data? + hideDeviceLink?: boolean; // Not passed as template data? + onDesktopOrTabletDevice?: boolean; // Not passed as template data? iosUrl?: string; androidUrl?: string; desktopLink?: string; diff --git a/libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts b/libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts index f80a919d339..aee6846f418 100644 --- a/libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts +++ b/libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts @@ -11,6 +11,13 @@ describe('EmailLinkBuilder', () => { privacyUrl: 'http://localhost:3030/privacy', supportUrl: 'http://localhost:3030/support', accountSettingsUrl: 'http://localhost:3030/settings', + passwordResetUrl: 'http://localhost:3030/reset_password_xxx', + verificationUrl: 'http://localhost:3030/verify_email', + verifyLoginUrl: 'http://localhost:3030/complete_signin', + prependVerificationSubdomain: { + enabled: true, + subdomain: 'test', + }, }; let linkBuilder: EmailLinkBuilder; 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 8a5732ea1fd..a194dd64135 100644 --- a/libs/accounts/email-renderer/src/renderer/email-link-builder.ts +++ b/libs/accounts/email-renderer/src/renderer/email-link-builder.ts @@ -1,6 +1,7 @@ /* 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/. */ +import parser from 'accept-language-parser'; const UTM_PREFIX = 'fx-'; @@ -81,10 +82,29 @@ const TEMPLATE_NAME_TO_CONTENT_MAP: Record = { export interface EmailLinkBuilderConfig { metricsEnabled: boolean; initiatePasswordResetUrl: string; + passwordResetUrl: string; privacyUrl: string; supportUrl: string; + accountSettingsUrl: string; + verificationUrl: string; + verifyLoginUrl: string; + prependVerificationSubdomain: { + enabled: boolean; + subdomain: string; + }; } +export type RecoveryLinkQueryParams = { + uid: string; + token: string; + code: string; + email: string; + resume: string; + emailToHashWith: string; + service?: string; + redirectTo?: string; +}; + export class EmailLinkBuilder { constructor(private readonly config: EmailLinkBuilderConfig) {} @@ -92,11 +112,14 @@ export class EmailLinkBuilder { * Common base URLs used in emails. Most often paired with UTM parameters * to attach tracking info. */ - private get urls() { + public get urls() { return { initiatePasswordReset: this.config.initiatePasswordResetUrl, + completePasswordReset: this.config.passwordResetUrl, privacy: this.config.privacyUrl, support: this.config.supportUrl, + accountSettings: this.config.accountSettingsUrl, + verificationUrl: this.config.verificationUrl, }; } @@ -132,25 +155,145 @@ export class EmailLinkBuilder { } } + buildPasswordChangeLink(): string { + throw new Error('TBD'); + } + + buildRevokeAccountRecoveryLink(): string { + throw new Error('TBD'); + } + + buildLowRecoveryCodesLink(): string { + throw new Error('TBD'); + } + + buildPostNewRecoveryCodesLink(): string { + throw new Error('TBD'); + } + + buildTwoFactorSettignsLink(): string { + throw new Error('TBD'); + } + + buildTwoFactorSupportLink(): string { + throw new Error('TBD'); + } + + buildAndroidLink(): string { + throw new Error('TBD'); + } + + buildIosLink(): string { + throw new Error('TBD'); + } + + buildTermsOfServiceDownloadLink(opts: { metricsEnabled: boolean }) { + throw new Error('TBD'); + } + + buildDefaultSurveyLink() { + throw new Error('TBD'); + // const defaultSureyUrl = 'https://survey.alchemer.com/s3/6534408/Privacy-Security-Product-Cancellation-of-Service-Q4-21'; + } + + buildPrivacyNoticeDownloadLink() { + throw new Error('TBD'); + } + + buildCancellationSurveyLink() { + throw new Error('TBD'); + } + + buildPrivacyLink(templateName: string, metricsEnabled: boolean) { + const privacyUrl = new URL(this.urls.privacy); + this.addUTMParams(privacyUrl, templateName, metricsEnabled, 'privacy'); + return privacyUrl.toString(); + } + + buildSupportLink(templateName: string, metricsEnabled: boolean) { + const supportUrl = new URL(this.urls.support); + this.addUTMParams(supportUrl, templateName, metricsEnabled, 'support'); + return supportUrl.toString(); + } + + buildRecoveryLink( + templateName: 'recovery', + metricsEnabled: boolean, + queryParams: RecoveryLinkQueryParams + ): string { + const url = new URL(this.urls.completePasswordReset); + this.addUTMParams(url, templateName, metricsEnabled); + Object.entries(queryParams).forEach((x) => { + const [k, v] = x; + if (v) { + url.searchParams.set(k, v); + } + }); + return url.toString(); + } + + buildMozillaSupportUrl(templateName: string, metricsEnabled: boolean) { + const mozillaSupportUrl = new URL('https://support.mozilla.org'); + this.addUTMParams(mozillaSupportUrl, templateName, metricsEnabled); + return mozillaSupportUrl.toString(); + } + /** + * Deprecated - Build links one at a time suing buildLink calls * Build common links with UTM parameters (privacy, support) * @param templateName * @param metricsEnabled - Inidicates if metrics/tracking is enabled for the user * @returns Object containing privacyUrl and supportUrl as strings */ buildCommonLinks(templateName: string, metricsEnabled: boolean) { - const privacyUrl = new URL(this.urls.privacy); - const supportUrl = new URL(this.urls.support); - - this.addUTMParams(privacyUrl, templateName, metricsEnabled, 'privacy'); - this.addUTMParams(supportUrl, templateName, metricsEnabled, 'support'); - return { - privacyUrl: privacyUrl.toString(), - supportUrl: supportUrl.toString(), + privacyUrl: this.buildPrivacyLink(templateName, metricsEnabled), + supportUrl: this.buildSupportLink(templateName, metricsEnabled), + mozillaSupportUrl: this.buildMozillaSupportUrl( + templateName, + metricsEnabled + ), }; } + buildPrimaryLink( + templateName: string, + metricsEnabled: boolean, + opts: { + to: string; + uid: string; + }, + primaryLink?: string + ): string { + // Create the URL and fill out query params + const url = new URL(primaryLink || this.urls.accountSettings); + + url.searchParams.set('email', opts.to); + url.searchParams.set('uid', opts.uid); + + this.addUTMParams(url, templateName, metricsEnabled); + + // Special case for verification subdomains. Locally these are disabled, but in + // other environmetns this will likely kick in! + if ( + primaryLink === this.config.verificationUrl || + primaryLink === this.config.verifyLoginUrl + ) { + url.host = `${this.config.prependVerificationSubdomain.subdomain}.${url.host}`; + } + + return url.toString(); + } + + /** + * Creates HTML link attributes to be written into document + * @param link The href link value + * @returns A set of attributes that can be placed in an + */ + buildLinkAttributes(link: string) { + return `href="${link}" style="color: #0a84ff; text-decoration: none; font-family: sans-serif;"`; + } + /** * Get the UTM campaign name for a template */ @@ -179,7 +322,8 @@ export class EmailLinkBuilder { link: string, templateName: string, opts: Record, - metricsEnabled: boolean + metricsEnabled: boolean, + content?: string ): string { const url = new URL(link); @@ -188,7 +332,7 @@ export class EmailLinkBuilder { url.searchParams.set(key, value); } } - this.addUTMParams(url, templateName, metricsEnabled); + this.addUTMParams(url, templateName, metricsEnabled, content); return url.toString(); } @@ -208,24 +352,77 @@ export class EmailLinkBuilder { return link.toString(); } - buildInitiatePasswordResetLink( - opts: { - uid: string; - token: string; - code: string; - email: string; - service?: string; - redirectTo?: string; - resume?: string; - emailToHashWith?: string; - }, + /** + * Builds a password reset link + * @param email Users email + * @param metricsEnabled If user has metrics enabled + * @returns A link to initiate a password reset + */ + buildResetLink( + templateName: string, + email: string, metricsEnabled: boolean ): string { return this.buildLinkWithQueryParamsAndUTM( this.urls.initiatePasswordReset, - 'recovery', - opts, - metricsEnabled + templateName, + { + email, + }, + metricsEnabled, + 'reset-password' ); } } + +// PORTED FROM fxa-shared/subscriptions/configuration/utils.ts +const DEFAULT_LOCALE = 'en'; +export const localizedPlanConfig = ( + planConfig: Readonly<{ + uiContent: Record; + urls: Record; + support: Record; + locales?: { + [key: string]: { + uiContent?: Record; + urls?: Record; + support?: Record; + }; + }; + }>, + userLocales: string[] +) => { + const planConfigLocales = Object.keys(planConfig.locales || {}); + const defaults = { + uiContent: planConfig.uiContent, + urls: planConfig.urls, + support: planConfig.support, + }; + + if (!planConfigLocales.length || !userLocales.length) { + return defaults; + } + + if (!planConfigLocales.includes(DEFAULT_LOCALE)) { + planConfigLocales.push(DEFAULT_LOCALE); + } + + const pickedLang = parser.pick(planConfigLocales, userLocales.join(',')); + + if ( + pickedLang && + pickedLang !== DEFAULT_LOCALE && + planConfig.locales && + planConfig.locales[pickedLang] + ) { + const localizedConfigs = planConfig.locales[pickedLang]; + + return { + uiContent: { ...defaults.uiContent, ...localizedConfigs.uiContent }, + urls: { ...defaults.urls, ...localizedConfigs.urls }, + support: { ...defaults.support, ...localizedConfigs.support }, + }; + } + + return defaults; +}; diff --git a/libs/accounts/email-renderer/src/renderer/fxa-email-renderer.spec.ts b/libs/accounts/email-renderer/src/renderer/fxa-email-renderer.spec.ts index d4319ab3b5d..90ed2b3746d 100644 --- a/libs/accounts/email-renderer/src/renderer/fxa-email-renderer.spec.ts +++ b/libs/accounts/email-renderer/src/renderer/fxa-email-renderer.spec.ts @@ -315,6 +315,7 @@ describe('FxA Email Renderer', () => { twoFactorSupportLink: mockLinkSupport, passwordChangeLink: mockLinkPasswordChange, ...defaultLayoutTemplateValues, + recoveryMethod: 'codes', }); expect(email).toBeDefined(); expect(email.html).toMatchSnapshot('matches full email snapshot'); diff --git a/libs/accounts/email-renderer/src/templates/passwordResetAccountRecovery/index.ts b/libs/accounts/email-renderer/src/templates/passwordResetAccountRecovery/index.ts index 7b986cfc063..bd5da163bd5 100644 --- a/libs/accounts/email-renderer/src/templates/passwordResetAccountRecovery/index.ts +++ b/libs/accounts/email-renderer/src/templates/passwordResetAccountRecovery/index.ts @@ -11,7 +11,7 @@ export type TemplateData = AppBadgesTemplateData & UserInfoTemplateData & { link: string; passwordChangeLink: string; - productName: string; + productName?: string; // Doesn't seem to actually be referenced... where did this come from? }; export const template = 'passwordResetAccountRecovery'; diff --git a/libs/accounts/email-renderer/src/templates/passwordResetWithRecoveryKeyPrompt/index.ts b/libs/accounts/email-renderer/src/templates/passwordResetWithRecoveryKeyPrompt/index.ts index 3ef4e7f9dc4..9acab582d94 100644 --- a/libs/accounts/email-renderer/src/templates/passwordResetWithRecoveryKeyPrompt/index.ts +++ b/libs/accounts/email-renderer/src/templates/passwordResetWithRecoveryKeyPrompt/index.ts @@ -9,7 +9,7 @@ export type TemplateData = AutomatedEmailChangePasswordTemplateData & UserInfoTemplateData & { link: string; passwordChangeLink: string; - productName: string; + productName?: string; // Not referenced? time: string; device: { uaBrowser: string; diff --git a/libs/accounts/email-renderer/src/templates/postAddTwoStepAuthentication/index.stories.ts b/libs/accounts/email-renderer/src/templates/postAddTwoStepAuthentication/index.stories.ts index c76d67029ae..bb050a36714 100644 --- a/libs/accounts/email-renderer/src/templates/postAddTwoStepAuthentication/index.stories.ts +++ b/libs/accounts/email-renderer/src/templates/postAddTwoStepAuthentication/index.stories.ts @@ -18,6 +18,8 @@ const data = { twoFactorSupportLink: 'https://support.mozilla.org/kb/secure-mozilla-account-two-step-authentication', supportUrl: 'https://support.mozilla.org', + recoveryMethod: 'phone', + maskedPhoneNumber: '1234', }; const createStory = storyWithProps( diff --git a/libs/accounts/email-renderer/src/templates/postAddTwoStepAuthentication/index.ts b/libs/accounts/email-renderer/src/templates/postAddTwoStepAuthentication/index.ts index a8b3eac3e3a..bee7cd18aa9 100644 --- a/libs/accounts/email-renderer/src/templates/postAddTwoStepAuthentication/index.ts +++ b/libs/accounts/email-renderer/src/templates/postAddTwoStepAuthentication/index.ts @@ -22,6 +22,8 @@ export type TemplateData = AutomatedEmailChangePasswordTemplateData & city: string; }; date: string; + recoveryMethod: string; + maskedPhoneNumber?: string; }; export const template = 'postAddTwoStepAuthentication'; diff --git a/packages/fxa-admin-server/src/config/index.ts b/packages/fxa-admin-server/src/config/index.ts index 03221d944c8..f67cce64e5f 100644 --- a/packages/fxa-admin-server/src/config/index.ts +++ b/packages/fxa-admin-server/src/config/index.ts @@ -735,6 +735,13 @@ const conf = convict({ default: 'http://localhost:3030/settings', }, }, + contentServer: { + url: { + doc: 'The url of the corresponding fxa-content-server instance', + default: 'http://localhost:3030', + env: 'CONTENT_SERVER_URL', + }, + }, smtp: { api: { host: { @@ -892,6 +899,18 @@ const conf = convict({ default: true, env: 'SMTP_METRICS_ENABLED', }, + verificationUrl: { + doc: 'Link for verification URLs in emails', + format: String, + default: 'http://localhost:3030/verify_email', + env: 'SMTP_VERIFICATION_URL', + }, + verifyLoginUrl: { + doc: 'Link for verifying logins used in emails', + format: String, + default: 'http://locahost:3030/complete_signin', + env: 'SMTP_VERIFY_LOGIN_URL', + }, }, bounces: { enabled: { diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index 5248c056cff..77ea70a4f8f 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -34,7 +34,7 @@ import { playStoreSubscriptionPurchaseToPlayStoreSubscriptionDTO, } from '../payments/iap/iap-formatter'; import { StripeHelper } from '../payments/stripe'; -import { AuthLogger, AuthRequest } from '../types'; +import { AuthClientInfoService, AuthLogger, AuthRequest } from '../types'; import { deleteAccountIfUnverified, fetchRpCmsData } from './utils/account'; import emailUtils from './utils/email'; import requestHelper from './utils/request_helper'; @@ -56,6 +56,9 @@ import { RelyingPartyConfigurationManager } from '@fxa/shared/cms'; import { OtpUtils } from './utils/otp'; import { getExistingSecondaryEmailRecord } from './emails'; import { Redis } from 'ioredis'; +import { FxaMailer } from '../senders/fxa-mailer'; +import { FxaMailerFormat } from '../senders/fxa-mailer-format'; +import { OAuthClientInfoServiceName } from '../senders/oauth_client_info'; const METRICS_CONTEXT_SCHEMA = require('../metrics/context').schema; @@ -78,6 +81,8 @@ export class AccountHandler { private accountTasks: DeleteAccountTasks; private profileClient: ProfileClient; private readonly cmsManager: RelyingPartyConfigurationManager | null; + private fxaMailer: FxaMailer; + private oauthClientInfoService: AuthClientInfoService; constructor( private log: AuthLogger, @@ -115,6 +120,8 @@ export class AccountHandler { this.cmsManager = Container.has(RelyingPartyConfigurationManager) ? Container.get(RelyingPartyConfigurationManager) : null; + this.fxaMailer = Container.get(FxaMailer); + this.oauthClientInfoService = Container.get(OAuthClientInfoServiceName); } private async generateRandomValues() { @@ -1369,28 +1376,62 @@ export class AccountHandler { uid: sessionToken.uid, }; if (!rpCmsConfig || !rpCmsConfig.NewDeviceLoginEmail) { - await this.mailer.sendNewDeviceLoginEmail( - accountRecord.emails, - accountRecord, - emailContext - ); + if (this.fxaMailer.canSend('newDeviceLogin')) { + const clientInfo = + await this.oauthClientInfoService.fetch(service); + await this.fxaMailer.sendNewDeviceLoginEmail({ + ...FxaMailerFormat.account(accountRecord), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + clientName: clientInfo.name, + showBannerWarning: false, + }); + } else { + await this.mailer.sendNewDeviceLoginEmail( + accountRecord.emails, + accountRecord, + emailContext + ); + } } else { - const rpEmailContext = { - ...emailContext, - target: 'strapi', - cmsRpClientId: rpCmsConfig.clientId, - cmsRpFromName: rpCmsConfig.shared?.emailFromName, - entrypoint, - logoUrl: rpCmsConfig?.shared?.emailLogoUrl, - logoAltText: rpCmsConfig?.shared?.emailLogoAltText, - logoWidth: rpCmsConfig?.shared?.emailLogoWidth, - ...rpCmsConfig.NewDeviceLoginEmail, - }; - await this.mailer.sendNewDeviceLoginEmail( - accountRecord.emails, - accountRecord, - rpEmailContext - ); + if (this.fxaMailer.canSend('newDeviceLogin')) { + const clientInfo = + await this.oauthClientInfoService.fetch(service); + await this.fxaMailer.sendNewDeviceLoginEmail({ + ...FxaMailerFormat.account(accountRecord), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.cmsLogo(rpCmsConfig.shared), + ...FxaMailerFormat.cmsEmailSubject( + rpCmsConfig.NewDeviceLoginEmail + ), + clientName: clientInfo.name, + showBannerWarning: false, + }); + } else { + const rpEmailContext = { + ...emailContext, + target: 'strapi', + cmsRpClientId: rpCmsConfig.clientId, + cmsRpFromName: rpCmsConfig.shared?.emailFromName, + entrypoint, + logoUrl: rpCmsConfig?.shared?.emailLogoUrl, + logoAltText: rpCmsConfig?.shared?.emailLogoAltText, + logoWidth: rpCmsConfig?.shared?.emailLogoWidth, + ...rpCmsConfig.NewDeviceLoginEmail, + }; + await this.mailer.sendNewDeviceLoginEmail( + accountRecord.emails, + accountRecord, + rpEmailContext + ); + } } } catch (err) { // If we couldn't email them, no big deal. Log @@ -1847,17 +1888,41 @@ export class AccountHandler { // successful login. The `isFirefoxMobileClient` option matches the // client-side check against `integration.isFirefoxMobileClient()`. if (hasTotpToken || isFirefoxMobileClient) { - return await this.mailer.sendPasswordResetWithRecoveryKeyPromptEmail( - account.emails, - account, - emailOptions - ); + if (this.fxaMailer.canSend('')) { + return await this.fxaMailer.sendPasswordResetWithRecoveryKeyPromptEmail( + { + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + } + ); + } else { + return await this.mailer.sendPasswordResetWithRecoveryKeyPromptEmail( + account.emails, + account, + emailOptions + ); + } } else { - return await this.mailer.sendPasswordResetAccountRecoveryEmail( - account.emails, - account, - emailOptions - ); + if (this.fxaMailer.canSend('passwordResetAccountRecovery')) { + return await this.fxaMailer.sendPasswordResetAccountRecoveryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + return await this.mailer.sendPasswordResetAccountRecoveryEmail( + account.emails, + account, + emailOptions + ); + } } } }; diff --git a/packages/fxa-auth-server/lib/routes/emails.js b/packages/fxa-auth-server/lib/routes/emails.js index 5ada7ad90fc..291ab98712d 100644 --- a/packages/fxa-auth-server/lib/routes/emails.js +++ b/packages/fxa-auth-server/lib/routes/emails.js @@ -10,6 +10,9 @@ const { AppError: error } = require('@fxa/accounts/errors'); const isA = require('joi'); const random = require('../crypto/random'); const Sentry = require('@sentry/node'); +const { Container } = require('typedi'); +const { FxaMailer } = require('../senders/fxa-mailer'); +const { FxaMailerFormat } = require('../senders/fxa-mailer-format'); const validators = require('./validators'); const { reportSentryError } = require('../sentry'); const { emailsMatch, normalizeEmail } = require('fxa-shared').email.helpers; @@ -135,6 +138,8 @@ module.exports = ( authServerCacheRedis, statsd ) => { + const fxaMailer = Container.get(FxaMailer); + const REMINDER_PATTERN = new RegExp( `^(?:${verificationReminders.keys.join('|')})$` ); @@ -231,7 +236,7 @@ module.exports = ( const geoData = request.app.geo; try { - await mailer.sendVerifySecondaryCodeEmail( + await fxaMailer.sendVerifySecondaryCodeEmail( [ { email, @@ -506,11 +511,20 @@ module.exports = ( } try { - await mailer.sendPostVerifySecondaryEmail([], account, { - acceptLanguage: request.app.acceptLanguage, - secondaryEmail: email, - uid, - }); + if (fxaMailer.canSend('postVerifySecondary')) { + fxaMailer.sendPostVerifySecondaryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.sync(false), + secondaryEmail: email, + }); + } else { + await mailer.sendPostVerifySecondaryEmail([], account, { + acceptLanguage: request.app.acceptLanguage, + secondaryEmail: email, + uid, + }); + } } catch (e) { log.error('secondary_email.sendPostVerifySecondaryEmail.error', { uid, @@ -1132,11 +1146,22 @@ module.exports = ( return item; } }); - await mailer.sendPostRemoveSecondaryEmail(emails, account, { - deviceId: sessionToken.deviceId, - secondaryEmail: email, - uid, - }); + + if (fxaMailer.canSend('postRemoveSecondary')) { + await fxaMailer.sendPostRemoveSecondaryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(false), + secondaryEmail: email, + }); + } else { + await mailer.sendPostRemoveSecondaryEmail(emails, account, { + deviceId: sessionToken.deviceId, + secondaryEmail: email, + uid, + }); + } return {}; }, @@ -1228,10 +1253,20 @@ module.exports = ( } const account = await db.account(uid); - await mailer.sendPostChangePrimaryEmail(account.emails, account, { - acceptLanguage: request.app.acceptLanguage, - uid, - }); + if (fxaMailer.canSend('postVerifySecondary')) { + await fxaMailer.sendPostChangePrimaryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.sync(false), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.metricsContext(request), + email: FxaMailerFormat.account(account).to, + }); + } else { + await mailer.sendPostChangePrimaryEmail(account.emails, account, { + acceptLanguage: request.app.acceptLanguage, + uid, + }); + } await recordSecurityEvent('account.primary_secondary_swapped', { db, diff --git a/packages/fxa-auth-server/lib/routes/linked-accounts.ts b/packages/fxa-auth-server/lib/routes/linked-accounts.ts index d167789e950..dfce97e3d47 100644 --- a/packages/fxa-auth-server/lib/routes/linked-accounts.ts +++ b/packages/fxa-auth-server/lib/routes/linked-accounts.ts @@ -40,6 +40,9 @@ import { ProfileClientServiceFailureError, } from '@fxa/profile/client'; import { StatsD } from 'hot-shots'; +import { Container } from 'typedi'; +import { FxaMailer } from '../senders/fxa-mailer'; +import { FxaMailerFormat } from '../senders/fxa-mailer-format'; const HEX_STRING = validators.HEX_STRING; @@ -56,6 +59,7 @@ export class LinkedAccountHandler { private db: any, private config: ConfigType, private mailer: any, + private fxaMailer: FxaMailer, private profile: ProfileClient, private statsd: StatsD, private glean: ReturnType @@ -375,27 +379,40 @@ export class LinkedAccountHandler { const geoData = request.app.geo; const ip = request.app.clientAddress; - const emailOptions = { - acceptLanguage: request.app.acceptLanguage, - deviceId, - flowId, - flowBeginTime, - ip, - location: geoData.location, - providerName: PROVIDER_NAME[provider], - 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: accountRecord.uid, - }; - await this.mailer.sendPostAddLinkedAccountEmail( - accountRecord.emails, - accountRecord, - emailOptions - ); + + if (this.fxaMailer.canSend('postAddLinkedAccount')) { + await this.fxaMailer.sendPostAddLinkedAccountEmail({ + ...FxaMailerFormat.account(accountRecord), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + providerName: PROVIDER_NAME[name], + }); + } else { + const emailOptions = { + acceptLanguage: request.app.acceptLanguage, + deviceId, + flowId, + flowBeginTime, + ip, + location: geoData.location, + providerName: PROVIDER_NAME[provider], + 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: accountRecord.uid, + }; + await this.mailer.sendPostAddLinkedAccountEmail( + accountRecord.emails, + accountRecord, + emailOptions + ); + } request.setMetricsFlowCompleteSignal('account.login', 'login'); switch (provider) { case 'google': @@ -606,11 +623,14 @@ export const linkedAccountRoutes = ( statsd: any, glean: ReturnType ) => { + const fxaMailer = Container.get(FxaMailer); + const handler = new LinkedAccountHandler( log, db, config, mailer, + fxaMailer, profile, statsd, glean diff --git a/packages/fxa-auth-server/lib/routes/password.ts b/packages/fxa-auth-server/lib/routes/password.ts index 6ff2fc54d5e..e32b0d354f7 100644 --- a/packages/fxa-auth-server/lib/routes/password.ts +++ b/packages/fxa-auth-server/lib/routes/password.ts @@ -9,12 +9,8 @@ import * as isA from 'joi'; import Container from 'typedi'; import { OtpManager, OtpStorage } from '@fxa/shared/otp'; -import { - constructLocalTimeAndDateStrings, - splitEmails, -} from '@fxa/accounts/email-renderer'; - import { FxaMailer } from '../senders/fxa-mailer'; +import { FxaMailerFormat } from '../senders/fxa-mailer-format'; import { ConfigType } from '../../config'; import PASSWORD_DOCS from '../../docs/swagger/password-api'; @@ -28,8 +24,6 @@ import * as requestHelper from '../routes/utils/request_helper'; import { AuthLogger, AuthRequest } from '../types'; import { recordSecurityEvent } from './utils/security-event'; import * as validators from './validators'; -import { formatUserAgentInfo } from 'fxa-shared/lib/user-agent'; -import { formatGeoData } from 'fxa-shared/lib/geo-data'; const HEX_STRING = validators.HEX_STRING; @@ -1033,38 +1027,48 @@ module.exports = function ( request.setMetricsFlowCompleteSignal(flowCompleteSignal); const code = await otpManager.create(account.uid); + const ip = request.app.clientAddress; + const service = payload.service || request.query.service; const { deviceId, flowId, flowBeginTime } = await request.app.metricsContext; const geoData = request.app.geo; const { browser: uaBrowser, + browserVersion: uaBrowserVersion, os: uaOS, osVersion: uaOSVersion, + deviceType: uaDeviceType, } = request.app.ua; - const { to, cc } = splitEmails(account.emails); - const { time, date, acceptLanguage, timeZone } = - constructLocalTimeAndDateStrings( - request.app.acceptLanguage, - geoData.timeZone - ); - await fxaMailer.sendPasswordForgotOtpEmail({ - metricsEnabled: account.metricsEnabled, - code, - uid: account.uid, - to, - cc, - deviceId, - flowId, - flowBeginTime, - time, - date, - acceptLanguage, - timeZone, - sync: false, - device: formatUserAgentInfo(uaBrowser, uaOS, uaOSVersion), - location: formatGeoData(geoData.location), - }); + if (fxaMailer.canSend('passwordForgotOtp')) { + await fxaMailer.sendPasswordForgotOtpEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.localTime(request), + code, + }); + } else { + await mailer.sendPasswordForgotOtpEmail(account.emails, account, { + code, + service, + acceptLanguage: request.app.acceptLanguage, + deviceId, + flowId, + flowBeginTime, + ip, + location: geoData.location, + timeZone: geoData.timeZone, + uaBrowser, + uaBrowserVersion, + uaOS, + uaOSVersion, + uaDeviceType, + uid: account.uid, + }); + } glean.resetPassword.otpEmailSent(request); diff --git a/packages/fxa-auth-server/lib/routes/recovery-codes.js b/packages/fxa-auth-server/lib/routes/recovery-codes.js index 0eabc18501a..e7cf5c49d86 100644 --- a/packages/fxa-auth-server/lib/routes/recovery-codes.js +++ b/packages/fxa-auth-server/lib/routes/recovery-codes.js @@ -12,6 +12,8 @@ const RECOVERY_CODES_DOCS = require('../../docs/swagger/recovery-codes-api').default; const { BackupCodeManager } = require('@fxa/accounts/two-factor'); const { recordSecurityEvent } = require('./utils/security-event'); +const { FxaMailer } = require('../senders/fxa-mailer'); +const { FxaMailerFormat } = require('../senders/fxa-mailer-format'); const RECOVERY_CODE_SANE_MAX_LENGTH = 20; @@ -20,6 +22,7 @@ module.exports = (log, db, config, customs, mailer, glean, statsd) => { const codeConfig = config.recoveryCodes; const RECOVERY_CODE_COUNT = (codeConfig && codeConfig.count) || 8; const backupCodeManager = Container.get(BackupCodeManager); + const fxaMailer = Container.get(FxaMailer); // Validate backup authentication codes const recoveryCodesSchema = validators.recoveryCodes( @@ -59,17 +62,27 @@ module.exports = (log, db, config, customs, mailer, glean, statsd) => { const account = await db.account(uid); const { acceptLanguage, clientAddress: geo, ua } = request.app; - await mailer.sendPostNewRecoveryCodesEmail(account.emails, account, { - acceptLanguage, - location: geo.location, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - }); + if (fxaMailer.canSend('postNewRecoveryCodes')) { + await fxaMailer.sendPostNewRecoveryCodesEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.sync(false), + ...FxaMailerFormat.localTime(request), + }); + } else { + await mailer.sendPostNewRecoveryCodesEmail(account.emails, account, { + acceptLanguage, + location: geo.location, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + }); + } log.info('account.recoveryCode.replaced', { uid }); await request.emitMetricsEvent('recoveryCode.replaced', { uid }); @@ -190,17 +203,27 @@ module.exports = (log, db, config, customs, mailer, glean, statsd) => { const account = await db.account(uid); const { acceptLanguage, clientAddress: geo, ua } = request.app; - await mailer.sendPostNewRecoveryCodesEmail(account.emails, account, { - acceptLanguage, - location: geo.location, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - }); + if (fxaMailer.canSend('postNewRecoveryCodes')) { + await fxaMailer.sendPostNewRecoveryCodesEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.sync(false), + ...FxaMailerFormat.localTime(request), + }); + } else { + await mailer.sendPostNewRecoveryCodesEmail(account.emails, account, { + acceptLanguage, + location: geo.location, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + }); + } await recordSecurityEvent('account.recovery_codes_created', { db, @@ -337,33 +360,61 @@ module.exports = (log, db, config, customs, mailer, glean, statsd) => { const account = await db.account(uid); const { acceptLanguage, clientAddress: ip, geo, ua } = request.app; - const mailerPromises = [ - mailer.sendPostSigninRecoveryCodeEmail(account.emails, account, { - acceptLanguage, - ip, - location: geo.location, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - }), - ]; - - if (remaining <= codeConfig.notifyLowCount) { - log.info('account.recoveryCode.notifyLowCount', { uid, remaining }); + const mailerPromises = []; + if (fxaMailer.canSend('postSigninRecoveryCode')) { + mailerPromises.push( + fxaMailer.sendPostSigninRecoveryCodeEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }) + ); + } else { mailerPromises.push( - mailer.sendLowRecoveryCodesEmail(account.emails, account, { + mailer.sendPostSigninRecoveryCodeEmail(account.emails, account, { acceptLanguage, - numberRemaining: remaining, + ip, + location: geo.location, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, uid, }) ); } + if (remaining <= codeConfig.notifyLowCount) { + log.info('account.recoveryCode.notifyLowCount', { uid, remaining }); + + if (fxaMailer.canSend('lowRecoveryCodes')) { + mailerPromises.push( + fxaMailer.sendLowRecoveryCodesEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(false), + numberRemaining: remaining, + }) + ); + } else { + mailerPromises.push( + mailer.sendLowRecoveryCodesEmail(account.emails, account, { + acceptLanguage, + numberRemaining: remaining, + uid, + }) + ); + } + } + await Promise.allSettled(mailerPromises); log.info('account.recoveryCode.verified', { uid }); diff --git a/packages/fxa-auth-server/lib/routes/recovery-key.js b/packages/fxa-auth-server/lib/routes/recovery-key.js index ae40ab9a488..0b4ba4883fc 100644 --- a/packages/fxa-auth-server/lib/routes/recovery-key.js +++ b/packages/fxa-auth-server/lib/routes/recovery-key.js @@ -14,6 +14,9 @@ const validators = require('./validators'); const isA = require('joi'); const { OAUTH_SCOPE_OLD_SYNC } = require('fxa-shared/oauth/constants'); const { list } = require('../oauth/authorized_clients'); +const { Container } = require('typedi'); +const { FxaMailer } = require('../senders/fxa-mailer'); +const { FxaMailerFormat } = require('../senders/fxa-mailer-format'); module.exports = ( log, @@ -24,6 +27,8 @@ module.exports = ( mailer, glean ) => { + const fxaMailer = Container.get(FxaMailer); + const routes = [ { method: 'POST', @@ -58,44 +63,67 @@ module.exports = ( async function sendKeyCreationEmail() { const account = await db.account(uid); - const { acceptLanguage, clientAddress: geo, ua } = request.app; - const emailOptions = { - acceptLanguage, - location: geo.location, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - }; - await mailer.sendPostAddAccountRecoveryEmail( - account.emails, - account, - emailOptions - ); + + if (fxaMailer.canSend('postAddAccountRecovery')) { + fxaMailer.sendPostAddAccountRecoveryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + const { acceptLanguage, clientAddress: geo, ua } = request.app; + const emailOptions = { + acceptLanguage, + location: geo.location, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + }; + await mailer.sendPostAddAccountRecoveryEmail( + account.emails, + account, + emailOptions + ); + } } async function sendKeyChangeEmail() { const account = await db.account(uid); - const { acceptLanguage, clientAddress: geo, ua } = request.app; - const emailOptions = { - acceptLanguage, - location: geo.location, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - }; - await mailer.sendPostChangeAccountRecoveryEmail( - account.emails, - account, - emailOptions - ); + if (fxaMailer.canSend('postChangeAccountRecovery')) { + await fxaMailer.sendPostChangeAccountRecoveryEmail( + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false) + ); + } else { + const { acceptLanguage, clientAddress: geo, ua } = request.app; + const emailOptions = { + acceptLanguage, + location: geo.location, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + }; + await mailer.sendPostChangeAccountRecoveryEmail( + account.emails, + account, + emailOptions + ); + } } async function postKeyCreation() { @@ -268,11 +296,22 @@ module.exports = ( uid, }; - await mailer.sendPostAddAccountRecoveryEmail( - account.emails, - account, - emailOptions - ); + if (fxaMailer.canSend('postAddAccountRecovery')) { + await fxaMailer.sendPostAddAccountRecoveryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + await mailer.sendPostAddAccountRecoveryEmail( + account.emails, + account, + emailOptions + ); + } } await recordSecurityEvent('account.recovery_key_challenge_success', { db, @@ -433,24 +472,34 @@ module.exports = ( const account = await db.account(uid); - const { acceptLanguage, clientAddress: geo, ua } = request.app; - const emailOptions = { - acceptLanguage, - location: geo.location, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - }; - - await mailer.sendPostRemoveAccountRecoveryEmail( - account.emails, - account, - emailOptions - ); + if (fxaMailer.canSend('')) { + await fxaMailer.sendPostRemoveAccountRecoveryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + const { acceptLanguage, clientAddress: geo, ua } = request.app; + const emailOptions = { + acceptLanguage, + location: geo.location, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + }; + await mailer.sendPostRemoveAccountRecoveryEmail( + account.emails, + account, + emailOptions + ); + } return {}; }, diff --git a/packages/fxa-auth-server/lib/routes/recovery-phone.ts b/packages/fxa-auth-server/lib/routes/recovery-phone.ts index 35d0d6e38d2..eff6e5662e6 100644 --- a/packages/fxa-auth-server/lib/routes/recovery-phone.ts +++ b/packages/fxa-auth-server/lib/routes/recovery-phone.ts @@ -38,6 +38,8 @@ import { Container } from 'typedi'; import { ConfigType } from '../../config'; import { PasswordForgotToken } from 'fxa-shared/db/models/auth'; import { OtpUtils } from './utils/otp'; +import { FxaMailer } from '../senders/fxa-mailer'; +import { FxaMailerFormat } from '../senders/fxa-mailer-format'; enum RecoveryPhoneStatus { SUCCESS = 'success', @@ -97,6 +99,7 @@ class RecoveryPhoneHandler { private readonly glean: GleanMetricsType, private readonly log: any, private readonly mailer: any, + private readonly fxaMailer: FxaMailer, private readonly statsd: any ) { this.recoveryPhoneService = Container.get(RecoveryPhoneService); @@ -441,20 +444,31 @@ class RecoveryPhoneHandler { }); try { - await this.mailer.sendPostSigninRecoveryPhoneEmail( - account.emails, - account, - { - acceptLanguage, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - } - ); + if (this.fxaMailer.canSend('postSigninRecoveryPhone')) { + await this.fxaMailer.sendPostSigninRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + await this.mailer.sendPostSigninRecoveryPhoneEmail( + account.emails, + account, + { + acceptLanguage, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + } + ); + } } catch (error) { // log email send error but don't throw // user should be allowed to proceed @@ -537,24 +551,39 @@ class RecoveryPhoneHandler { // when 2fa setup is complete if (hasTotpToken) { try { - await this.mailer.sendPostAddRecoveryPhoneEmail( - account.emails, - account, - { - acceptLanguage, + if (this.fxaMailer.canSend('postAddRecoveryPhone')) { + await this.fxaMailer.sendPostAddRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), maskedLastFourPhoneNumber: `••••••${this.recoveryPhoneService.stripPhoneNumber( phoneNumber || '', 4 )}`, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - } - ); + }); + } else { + await this.mailer.sendPostAddRecoveryPhoneEmail( + account.emails, + account, + { + acceptLanguage, + maskedLastFourPhoneNumber: `••••••${this.recoveryPhoneService.stripPhoneNumber( + phoneNumber || '', + 4 + )}`, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + } + ); + } } catch (error) { // log email send error but don't throw // user should be allowed to proceed @@ -661,20 +690,31 @@ class RecoveryPhoneHandler { const account = await this.db.account(uid); try { - await this.mailer.sendPostChangeRecoveryPhoneEmail( - account.emails, - account, - { - acceptLanguage, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - } - ); + if (this.fxaMailer.canSend('postChangeRecoveryPhone')) { + await this.fxaMailer.sendPostChangeRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + await this.mailer.sendPostChangeRecoveryPhoneEmail( + account.emails, + account, + { + acceptLanguage, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + } + ); + } } catch (error) { // log error, but don't throw // user should be allowed to proceed if email or security event fails @@ -747,20 +787,31 @@ class RecoveryPhoneHandler { const { acceptLanguage, geo, ua } = request.app; try { - await this.mailer.sendPasswordResetRecoveryPhoneEmail( - account.emails, - account, - { - acceptLanguage, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - } - ); + if (this.fxaMailer.canSend('passwordResetRecoveryPhone')) { + await this.fxaMailer.sendPasswordResetRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + await this.mailer.sendPasswordResetRecoveryPhoneEmail( + account.emails, + account, + { + acceptLanguage, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + } + ); + } } catch (error) { this.log.error( 'account.recoveryPhone.phonePasswordResetNotification.error', @@ -815,20 +866,31 @@ class RecoveryPhoneHandler { account, }); - await this.mailer.sendPostRemoveRecoveryPhoneEmail( - account.emails, - account, - { - acceptLanguage, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - } - ); + if (this.fxaMailer.canSend('postRemoveRecoveryPhone')) { + await this.fxaMailer.sendPostRemoveRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(false), + }); + } else { + await this.mailer.sendPostRemoveRecoveryPhoneEmail( + account.emails, + account, + { + acceptLanguage, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, + uid, + } + ); + } } catch (error) { // log email send error but don't throw // user should be allowed to proceed @@ -1015,12 +1077,15 @@ export const recoveryPhoneRoutes = ( } return true; }; + const fxaMailer = Container.get(FxaMailer); + const recoveryPhoneHandler = new RecoveryPhoneHandler( customs, db, glean, log, mailer, + fxaMailer, statsd ); const routes = [ diff --git a/packages/fxa-auth-server/lib/routes/session.js b/packages/fxa-auth-server/lib/routes/session.js index 1df6a0e37a4..bd18c25dfaf 100644 --- a/packages/fxa-auth-server/lib/routes/session.js +++ b/packages/fxa-auth-server/lib/routes/session.js @@ -20,6 +20,9 @@ const { getOptionalCmsEmailConfig } = require('./utils/account'); const { Container } = require('typedi'); const { RelyingPartyConfigurationManager } = require('@fxa/shared/cms'); const authMethods = require('../authMethods'); +const { FxaMailer } = require('../senders/fxa-mailer'); +const { FxaMailerFormat } = require('../senders/fxa-mailer-format'); +const { OAuthClientInfoServiceName } = require('../senders/oauth_client_info'); module.exports = function ( log, @@ -46,6 +49,9 @@ module.exports = function ( ? Container.get(RelyingPartyConfigurationManager) : null; + const fxaMailer = Container.get(FxaMailer); + const oauthClientInfoService = Container.get(OAuthClientInfoServiceName); + const routes = [ { method: 'POST', @@ -516,26 +522,40 @@ module.exports = function ( // Send new device login notification email after successful verification const geoData = request.app.geo; const service = options.service || request.query.service; - const emailOptions = { - acceptLanguage: request.app.acceptLanguage, - ip: request.app.clientAddress, - location: geoData.location, - service, - timeZone: geoData.timeZone, - uaBrowser: sessionToken.uaBrowser, - uaBrowserVersion: sessionToken.uaBrowserVersion, - uaOS: sessionToken.uaOS, - uaOSVersion: sessionToken.uaOSVersion, - uaDeviceType: sessionToken.uaDeviceType, - uid, - }; try { - await mailer.sendNewDeviceLoginEmail( - account.emails, - account, - emailOptions - ); + if (fxaMailer.canSend('newDeviceLogin')) { + const clientInfo = await oauthClientInfoService.fetch(service); + await fxaMailer.sendNewDeviceLoginEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + clientName: clientInfo.name, + showBannerWarning: false, + }); + } else { + const emailOptions = { + acceptLanguage: request.app.acceptLanguage, + ip: request.app.clientAddress, // TODO: Double check this... It doesn't seem to be used? + location: geoData.location, + service, + timeZone: geoData.timeZone, + uaBrowser: sessionToken.uaBrowser, + uaBrowserVersion: sessionToken.uaBrowserVersion, + uaOS: sessionToken.uaOS, + uaOSVersion: sessionToken.uaOSVersion, + uaDeviceType: sessionToken.uaDeviceType, + uid, + }; + await mailer.sendNewDeviceLoginEmail( + account.emails, + account, + emailOptions + ); + } } catch (err) { log.trace('Session.verify_code.sendNewDeviceLoginEmail.error', { error: err, @@ -842,11 +862,25 @@ module.exports = function ( }; try { - await mailer.sendNewDeviceLoginEmail( - account.emails, - account, - emailOptions - ); + if (fxaMailer.canSend('newDeviceLogin')) { + const clientInfo = await oauthClientInfoService.fetch(service); + await fxaMailer.sendNewDeviceLoginEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + clientName: clientInfo.name, + showBannerWarning: false, + }); + } else { + await mailer.sendNewDeviceLoginEmail( + account.emails, + account, + emailOptions + ); + } } catch (err) { log.trace('Session.verify_push.sendNewDeviceLoginEmail.error', { error: err, diff --git a/packages/fxa-auth-server/lib/routes/totp.js b/packages/fxa-auth-server/lib/routes/totp.js index 4421634b909..aaaf26f9c70 100644 --- a/packages/fxa-auth-server/lib/routes/totp.js +++ b/packages/fxa-auth-server/lib/routes/totp.js @@ -21,6 +21,9 @@ const { } = require('@fxa/accounts/recovery-phone'); const { BackupCodeManager } = require('@fxa/accounts/two-factor'); const { recordSecurityEvent } = require('./utils/security-event'); +const { FxaMailer } = require('../senders/fxa-mailer'); +const { FxaMailerFormat } = require('../senders/fxa-mailer-format'); +const { OAuthClientInfoServiceName } = require('../senders/oauth_client_info'); const RECOVERY_CODE_SANE_MAX_LENGTH = 20; @@ -64,6 +67,8 @@ module.exports = ( promisify(qrcode.toDataURL); + const fxaMailer = Container.get(FxaMailer); + const oauthClientInfoService = Container.get(OAuthClientInfoServiceName); const recoveryPhoneService = Container.get(RecoveryPhoneService); const backupCodeManager = Container.get(BackupCodeManager); @@ -217,25 +222,37 @@ module.exports = ( const geoData = request.app.geo; const ip = request.app.clientAddress; const service = request.payload.service || request.query.service; - const emailOptions = { - acceptLanguage: request.app.acceptLanguage, - ip: ip, - location: geoData.location, - service: service, - 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: uid, - }; + try { - await mailer.sendPostChangeTwoStepAuthenticationEmail( - account.emails, - account, - emailOptions - ); + if (fxaMailer.canSend('postChangeTwoStepAuthentication')) { + await fxaMailer.sendPostChangeTwoStepAuthenticationEmail({ + ...FxaMailer.Format.account(account), + ...FxaMailer.Format.metricsContext(request), + ...FxaMailer.Format.device(request), + ...FxaMailer.Format.sync(service), + ...FxaMailer.Format.location(request), + ...FxaMailer.Format.localTime(request), + }); + } else { + const emailOptions = { + acceptLanguage: request.app.acceptLanguage, + ip: ip, + location: geoData.location, + service: service, + 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: uid, + }; + await mailer.sendPostChangeTwoStepAuthenticationEmail( + account.emails, + account, + emailOptions + ); + } } catch (error) { log.error('mailer.sendPostChangeTwoStepAuthenticationEmail', { error, @@ -269,25 +286,36 @@ module.exports = ( if (hasEnabledToken) { const account = await db.account(uid); - const geoData = request.app.geo; - const emailOptions = { - acceptLanguage: request.app.acceptLanguage, - 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, - }; try { - await mailer.sendPostRemoveTwoStepAuthenticationEmail( - account.emails, - account, - emailOptions - ); + if (fxaMailer.canSend('postRemoveTwoStepAuthentication')) { + await fxaMailer.sendPostRemoveTwoStepAuthenticationEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.sync(service), + }); + } else { + const geoData = request.app.geo; + const emailOptions = { + acceptLanguage: request.app.acceptLanguage, + 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, + }; + await mailer.sendPostRemoveTwoStepAuthenticationEmail( + account.emails, + account, + emailOptions + ); + } } catch (err) { // If email fails, log the error without aborting the operation. log.error('mailer.sendPostRemoveTwoStepAuthenticationEmail', { @@ -684,19 +712,6 @@ module.exports = ( const geoData = request.app.geo; const ip = request.app.clientAddress; const service = request.payload?.service || request.query?.service; - const emailOptions = { - acceptLanguage: request.app.acceptLanguage, - ip, - location: geoData.location, - service, - 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, - }; // include recovery method context if available const result = await recoveryPhoneService.hasConfirmed(uid); @@ -704,15 +719,45 @@ module.exports = ( ? recoveryPhoneService.maskPhoneNumber(result.phoneNumber) : undefined; + // TODO: Had to add this. Seems to be needed. + const recoveryMethod = maskedPhoneNumber ? 'phone' : 'codes'; + try { - await mailer.sendPostAddTwoStepAuthenticationEmail( - account.emails, - account, - { - ...emailOptions, + if (fxaMailer.canSend('postAddTwoStepAuthentication')) { + await fxaMailer.sendPostAddTwoStepAuthenticationEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.location(request), + recoveryMethod, maskedPhoneNumber, - } - ); + }); + } else { + const emailOptions = { + acceptLanguage: request.app.acceptLanguage, + ip, + location: geoData.location, + service, + 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, + }; + await mailer.sendPostAddTwoStepAuthenticationEmail( + account.emails, + account, + { + ...emailOptions, + recoveryMethod, + maskedPhoneNumber, + } + ); + } } catch (error) { log.error('mailer.sendPostAddTwoStepAuthenticationEmail', { error, @@ -930,33 +975,61 @@ module.exports = ( const { remaining } = await db.consumeRecoveryCode(uid, code); - const mailerPromises = [ - mailer.sendPostConsumeRecoveryCodeEmail(account.emails, account, { - acceptLanguage, - ip, - location: geo.location, - timeZone: geo.timeZone, - uaBrowser: ua.browser, - uaBrowserVersion: ua.browserVersion, - uaOS: ua.os, - uaOSVersion: ua.osVersion, - uaDeviceType: ua.deviceType, - uid, - }), - ]; - - if (remaining <= codeConfig.notifyLowCount) { - log.info('account.recoveryCode.notifyLowCount', { uid, remaining }); + const mailerPromises = []; + if (fxaMailer.canSend('postConsumeRecoveryCode')) { + mailerPromises.push( + fxaMailer.sendPostConsumeRecoveryCodeEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.sync(service), + }) + ); + } else { mailerPromises.push( - mailer.sendLowRecoveryCodesEmail(account.emails, account, { + mailer.sendPostConsumeRecoveryCodeEmail(account.emails, account, { acceptLanguage, - numberRemaining: remaining, + ip, + location: geo.location, + timeZone: geo.timeZone, + uaBrowser: ua.browser, + uaBrowserVersion: ua.browserVersion, + uaOS: ua.os, + uaOSVersion: ua.osVersion, + uaDeviceType: ua.deviceType, uid, }) ); } + if (remaining <= codeConfig.notifyLowCount) { + log.info('account.recoveryCode.notifyLowCount', { uid, remaining }); + + if (fxaMailer.canSend('lowRecoveryCodes')) { + mailerPromises.push( + fxaMailer.sendLowRecoveryCodesEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + numberRemaining: remaining, + }) + ); + } else { + mailerPromises.push( + mailer.sendLowRecoveryCodesEmail(account.emails, account, { + acceptLanguage, + numberRemaining: remaining, + uid, + }) + ); + } + } + await Promise.all(mailerPromises); glean.resetPassword.twoFactorRecoveryCodeSuccess(request, { @@ -1102,11 +1175,25 @@ module.exports = ( uid: sessionToken.uid, }; - return mailer.sendNewDeviceLoginEmail( - account.emails, - account, - emailOptions - ); + if (fxaMailer.canSend('newDeviceLogin')) { + const clientInfo = await oauthClientInfoService.fetch(service); + return await fxaMailer.sendNewDeviceLoginEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + clientName: clientInfo.name, + showBannerWarning: false, + }); + } else { + return mailer.sendNewDeviceLoginEmail( + account.emails, + account, + emailOptions + ); + } } }, }, diff --git a/packages/fxa-auth-server/lib/routes/utils/oauth.js b/packages/fxa-auth-server/lib/routes/utils/oauth.js index d383b5464e6..5dbb9249c55 100644 --- a/packages/fxa-auth-server/lib/routes/utils/oauth.js +++ b/packages/fxa-auth-server/lib/routes/utils/oauth.js @@ -12,6 +12,12 @@ const { } = require('fxa-shared/oauth/constants'); const token = require('../../oauth/token'); const ScopeSet = require('fxa-shared').oauth.scopes; +const { Container } = require('typedi'); +const { FxaMailer } = require('../../senders/fxa-mailer'); +const { FxaMailerFormat } = require('../../senders/fxa-mailer-format'); +const { + OAuthClientInfoServiceName, +} = require('../../senders/oauth_client_info'); // right now we only care about notifications for the following scopes // if not a match, then we don't notify @@ -25,6 +31,9 @@ module.exports = { request, grant ) { + const fxaMailer = Container.get(FxaMailer); + const oauthClientInfoService = Container.get(OAuthClientInfoServiceName); + const clientId = request.payload.client_id; const scopeSet = ScopeSet.fromString(grant.scope); const credentials = (request.auth && request.auth.credentials) || {}; @@ -83,11 +92,26 @@ module.exports = { timeZone: geoData.timeZone, uid: credentials.uid, }; - await mailer.sendNewDeviceLoginEmail( - account.emails, - account, - emailOptions - ); + + if (fxaMailer.canSend('newDeviceLogin')) { + const clientInfo = await oauthClientInfoService.fetch(clientId); + await fxaMailer.sendNewDeviceLoginEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(clientId), + clientName: clientInfo.name, + showBannerWarning: false, + }); + } else { + await mailer.sendNewDeviceLoginEmail( + account.emails, + account, + emailOptions + ); + } } } }, diff --git a/packages/fxa-auth-server/lib/senders/fxa-mailer-format.ts b/packages/fxa-auth-server/lib/senders/fxa-mailer-format.ts new file mode 100644 index 00000000000..a11f0d31b1e --- /dev/null +++ b/packages/fxa-auth-server/lib/senders/fxa-mailer-format.ts @@ -0,0 +1,109 @@ +/* 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/. */ +import { formatUserAgentInfo } from 'fxa-shared/lib/user-agent'; +import { constructLocalTimeAndDateStrings } from '@fxa/accounts/email-renderer'; + +export const FxaMailerFormat = { + metricsContext(request: { + app: { + metricsContext: { + deviceId?: string; + flowId: string; + flowBeginTime: number; + }; + }; + }) { + return { + deviceId: request.app.metricsContext.deviceId, + flowId: request.app.metricsContext.flowId, + flowBeginTime: request.app.metricsContext.flowBeginTime, + }; + }, + account(account: { + uid: string; + email: string; + emails: Array<{ isPrimary: boolean; isVerified: boolean; email: string }>; + metricsOptOutAt: number | undefined | null; + }) { + return { + to: + account.emails?.find((e) => e.isPrimary && e.isVerified)?.email || + account.email, + cc: account.emails + ?.filter((e) => !e.isPrimary && e.isVerified) + .map((e) => e.email), + metricsEnabled: !account.metricsOptOutAt, + uid: account.uid, + }; + }, + device(request: { + app: { ua: { browser?: string; os?: string; osVersion?: string } }; + }) { + return { + device: formatUserAgentInfo(request.app.ua), + }; + }, + localTime(request: { + app: { geo: { timeZone: string }; acceptLanguage: string }; + }) { + return constructLocalTimeAndDateStrings( + request.app.geo.timeZone, + request.app.acceptLanguage + ); + }, + location(request: { + app: { + geo: { + location?: { + city: string; + state: string; + country: string; + countryCode: string; + postalCode?: string; + }; + }; + }; + }): { + location: { + stateCode: string; + country: string; + city: string; + }; + } { + return { + location: { + stateCode: request.app.geo.location?.state || '', + country: request.app.geo.location?.country || '', + city: request.app.geo.location?.city || '', + }, + }; + }, + sync(service: string | false) { + return { + sync: service === 'sync', + }; + }, + cmsLogo(opts?: { + emailLogoAltText: string | null; + emailLogoUrl: string | null; + emailLogoWidth: string | null; + }) { + return { + logoAltText: opts?.emailLogoAltText || undefined, + logoUrl: opts?.emailLogoUrl || undefined, + logoWidth: opts?.emailLogoWidth || undefined, + }; + }, + cmsEmailSubject(opts?: { + description: string | null; + headline: string | null; + subject: string | null; + }) { + return { + description: opts?.description || undefined, + headline: opts?.headline || undefined, + subject: opts?.subject || undefined, + }; + }, +}; 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 new file mode 100644 index 00000000000..2902df01e5e --- /dev/null +++ b/packages/fxa-auth-server/lib/senders/fxa-mailer-sanity-check.ts @@ -0,0 +1,361 @@ +/* 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/. */ + +import { EmailSender } from '../../../../libs/accounts/email-sender/src'; +import { FxaMailer } from './fxa-mailer'; +import { FxaMailerFormat } from './fxa-mailer-format'; +import { + EmailLinkBuilder, + NodeRendererBindings, +} from '@fxa/accounts/email-renderer'; +import { ConfigType } from '../../config'; + +/** + * This is jsut a strong typed driver that allows us to validate the params we pass into FxaMailer + * functions are type safe. Since there's still a lot of code in auth-server that isn't type safe + * this gives us some level of sanity that we are invoking the mailer methods correctly. + */ + +// Create dummy mailer +const fxaMailer = new FxaMailer( + {} as unknown as EmailSender, + {} as unknown as EmailLinkBuilder, + {} as unknown as ConfigType['smtp'], + {} as unknown as NodeRendererBindings +); + +// Setup fake / mocked objects +const account = { + uid: '', + metricsOptOutAt: null, + email: 'foo', + primaryEmail: { email: 'foo', isPrimary: true, isVerified: true }, + emails: [ + { email: 'foo', isPrimary: true, isVerified: true }, + { email: 'bar', isPrimary: false, isVerified: true }, + ], +}; + +const request = { + payload: { + metricsContext: { + flowId: '', + flowBeginTime: 0, + deviceId: '', + }, + }, + app: { + acceptLanguage: 'en', + geo: { + timeZone: '', + location: { + city: '', + state: '', + stateCode: '', + country: '', + countryCode: '', + }, + }, + clientAddress: '', + metricsContext: { + flowId: '', + flowBeginTime: 0, + deviceId: '', + }, + ua: { + browser: '', + os: '', + osVersion: '', + }, + }, + auth: { + credentials: { + uaBrowser: '', + uaBrowserVersion: '', + uaOS: '', + uaOSVersion: '', + uaDeviceType: '', + }, + }, +}; + +const rpCmsConfig = { + shared: { + emailLogoUrl: '', + emailLogoAltText: '', + emailLogoWidth: '', + }, +}; + +const service = 'sync'; + +async function __sendRecoveryEmail() { + await fxaMailer.sendRecoveryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.sync(service), + token: 'todo', + code: 'todo', + email: FxaMailerFormat.account(account).to, + resume: 'todo', + emailToHashWith: account.email, + }); +} + +async function __sendPasswordForgotOtpEmail() { + await fxaMailer.sendPasswordForgotOtpEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.localTime(request), + code: 'todo', + }); +} + +async function _sendPostVerifySecondaryEmail() { + fxaMailer.sendPostVerifySecondaryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.sync(service), + secondaryEmail: 'foo@mozilla.com', + }); +} + +async function _sendPostChangePrimaryEmail() { + await fxaMailer.sendPostChangePrimaryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.sync(false), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.cmsLogo(rpCmsConfig.shared), + email: FxaMailerFormat.account(account).to, + }); +} + +async function _sendPostRemoveSecondaryEmail() { + await fxaMailer.sendPostRemoveSecondaryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(false), + secondaryEmail: 'foo@mozilla.com', + }); +} + +async function _sendPostAddLinkedAccountEmail() { + await fxaMailer.sendPostAddLinkedAccountEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + providerName: 'todo', + }); +} + +async function _sendNewDeviceLoginEmail() { + await fxaMailer.sendNewDeviceLoginEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.metricsContext(request), + clientName: 'sync', + showBannerWarning: false, + }); +} + +async function _sendPostAddTwoStepAuthenticationEmail() { + await fxaMailer.sendPostAddTwoStepAuthenticationEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.location(request), + recoveryMethod: 'phone', + maskedPhoneNumber: '3242', + }); +} + +async function _sendPostChangeTwoStepAuthenticationEmail() { + await fxaMailer.sendPostChangeTwoStepAuthenticationEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.localTime(request), + }); +} + +async function __sendPostRemoveTwoStepAuthenticationEmail() { + await fxaMailer.sendPostRemoveTwoStepAuthenticationEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function _sendPostNewRecoveryCodesEmail() { + await fxaMailer.sendPostNewRecoveryCodesEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.sync(service), + ...FxaMailerFormat.localTime(request), + }); +} + +async function __sendPostConsumeRecoveryCodeEmail() { + await fxaMailer.sendPostConsumeRecoveryCodeEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendLowRecoveryCodesEmail() { + await fxaMailer.sendLowRecoveryCodesEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.sync(service), + numberRemaining: 1, + }); +} + +async function __sendPostSigninRecoveryCodeEmail() { + await fxaMailer.sendPostSigninRecoveryCodeEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPostAddRecoveryPhoneEmail() { + await fxaMailer.sendPostAddRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + maskedLastFourPhoneNumber: '4444', + }); +} + +async function __sendPostChangeRecoveryPhoneEmail() { + await fxaMailer.sendPostChangeRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPostRemoveRecoveryPhoneEmail() { + await fxaMailer.sendPostRemoveRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPasswordResetRecoveryPhoneEmail() { + await fxaMailer.sendPasswordResetRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPostSigninRecoveryPhoneEmail() { + await fxaMailer.sendPostSigninRecoveryPhoneEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPostAddAccountRecoveryEmail() { + await fxaMailer.sendPostAddAccountRecoveryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPostChangeAccountRecoveryEmail() { + await fxaMailer.sendPostChangeAccountRecoveryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPostRemoveAccountRecoveryEmail() { + await fxaMailer.sendPostRemoveAccountRecoveryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPasswordResetAccountRecoveryEmail() { + await fxaMailer.sendPasswordResetAccountRecoveryEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} + +async function __sendPasswordResetWithRecoveryKeyPromptEmail() { + await fxaMailer.sendPasswordResetWithRecoveryKeyPromptEmail({ + ...FxaMailerFormat.account(account), + ...FxaMailerFormat.metricsContext(request), + ...FxaMailerFormat.localTime(request), + ...FxaMailerFormat.location(request), + ...FxaMailerFormat.device(request), + ...FxaMailerFormat.sync(service), + }); +} diff --git a/packages/fxa-auth-server/lib/senders/fxa-mailer.ts b/packages/fxa-auth-server/lib/senders/fxa-mailer.ts index cccacf09447..523568bb4b0 100644 --- a/packages/fxa-auth-server/lib/senders/fxa-mailer.ts +++ b/packages/fxa-auth-server/lib/senders/fxa-mailer.ts @@ -9,11 +9,39 @@ import { WithFxaLayouts, recovery, passwordForgotOtp, + postVerifySecondary, + postChangePrimary, + postAddLinkedAccount, + newDeviceLogin, + postAddTwoStepAuthentication, + postChangeTwoStepAuthentication, + postRemoveTwoStepAuthentication, + postNewRecoveryCodes, + postConsumeRecoveryCode, + lowRecoveryCodes, + postSigninRecoveryCode, + postAddRecoveryPhone, + postChangeRecoveryPhone, + postRemoveRecoveryPhone, + passwordResetRecoveryPhone, + postSigninRecoveryPhone, + postAddAccountRecovery, + postChangeAccountRecovery, + postRemoveAccountRecovery, + passwordResetAccountRecovery, + passwordResetWithRecoveryKeyPrompt, + postRemoveSecondary, + RenderedTemplate, + RecoveryLinkQueryParams, } 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'; const SERVER = 'fxa-auth-server'; @@ -32,6 +60,7 @@ type EmailFlowParams = { type EmailSenderOpts = AccountOpts & EmailFlowParams & { + cmsRpFromName?: string; to: string; cc?: string[]; }; @@ -40,8 +69,22 @@ type EmailSenderOpts = AccountOpts & * Some links are required on the underlying types, but shouldn't be * the responsibility of the caller to provide. Use this to wrap templateValues * and layoutTemplateValues types to omit those fields. + * + * Additional properties can be omitted by specifying them as the second generic parameter K. */ -type OmitCommonLinks = Omit; +type OmitCommonLinks = Omit< + T, + | 'supportUrl' + | 'privacyUrl' + | 'link' + | 'passwordChangeLink' + | 'revokeAccountRecoveryLink' + | 'mozillaSupportUrl' + | 'twoFactorSupportLink' + | 'twoFactorSettingsLink' + | 'resetLink' + | K +>; export class FxaMailer extends FxaEmailRenderer { constructor( @@ -69,57 +112,335 @@ export class FxaMailer extends FxaEmailRenderer { async sendRecoveryEmail( opts: EmailSenderOpts & OmitCommonLinks & - OmitCommonLinks & { - token: string; - code: string; - emailToHashWith?: string; - service?: string; - redirectTo?: string; - resume?: string; - } & OmitCommonLinks> - ) { - const { template: name, version } = recovery; - - // Build links for template - const initiatePasswordResetLink = - this.linkBuilder.buildInitiatePasswordResetLink( - { - uid: opts.uid, - token: opts.token, - code: opts.code, - email: opts.to, - service: opts.service, - redirectTo: opts.redirectTo, - resume: opts.resume, - emailToHashWith: opts.emailToHashWith, - }, - opts.metricsEnabled - ); - const { supportUrl, privacyUrl } = this.linkBuilder.buildCommonLinks( - name, - opts.metricsEnabled - ); + OmitCommonLinks & + OmitCommonLinks> & + RecoveryLinkQueryParams + ) { + const { template, version } = recovery; + const { metricsEnabled } = opts; + const links = { + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + link: this.linkBuilder.buildRecoveryLink(template, metricsEnabled, opts), + }; + const headers = this.buildHeaders( + { template, version }, + { + 'X-Link': links.link, + 'X-Recovery-Code': opts.code, + }, + opts + ); const rendered = await this.renderRecovery({ ...opts, - link: initiatePasswordResetLink, - supportUrl, - privacyUrl, + ...links, }); + return this.sendEmail(opts, headers, rendered); + } - const headers = this.emailSender.buildHeaders({ - context: { ...opts, serverName: SERVER, language: opts.acceptLanguage }, - headers: { - 'X-Link': initiatePasswordResetLink, - 'X-Recovery-Code': opts.code, - }, - template: { - name, - version, + /** + * Renders and sends the password forgot OTP email. + * @param opts + * @returns + */ + async sendPasswordForgotOtpEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks> + ) { + const { template, version } = passwordForgotOtp; + const { metricsEnabled } = opts; + const links = { + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'x-password-forgot-otp': opts.code }, + opts + ); + const rendered = await this.renderPasswordForgotOtp({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostVerifySecondaryEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postVerifySecondary; + const { metricsEnabled } = opts; + const links = { + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostVerifySecondary({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostChangePrimaryEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postVerifySecondary; + const { metricsEnabled } = opts; + const links = { + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostChangePrimary({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostRemoveSecondaryEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postRemoveSecondary; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostRemoveSecondary({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostAddLinkedAccountEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postAddLinkedAccount; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { + 'X-Link': links.passwordChangeLink, + 'X-Linked-Account-Provider-Id': opts.providerName, }, + opts + ); + const rendered = await this.renderPostAddLinkedAccount({ + ...opts, + ...links, }); + return this.sendEmail(opts, headers, rendered); + } - // Send email + async sendNewDeviceLoginEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = newDeviceLogin; + const { metricsEnabled } = opts; + const links = { + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + mozillaSupportUrl: this.linkBuilder.buildMozillaSupportUrl( + template, + metricsEnabled + ), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.passwordChangeLink }, + opts + ); + const rendered = await this.renderNewDeviceLogin({ + ...opts, + ...links, + }); + + return this.sendEmail(opts, headers, rendered); + } + + async sendPostAddTwoStepAuthenticationEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postAddTwoStepAuthentication; + const { metricsEnabled } = opts; + const links = { + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + twoFactorSupportLink: this.linkBuilder.buildTwoFactorSupportLink(), + link: this.linkBuilder.buildPrimaryLink(template, metricsEnabled, opts), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostAddTwoStepAuthentication({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostChangeTwoStepAuthenticationEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postChangeTwoStepAuthentication; + const { metricsEnabled } = opts; + const links = { + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + twoFactorSupportLink: this.linkBuilder.buildTwoFactorSupportLink(), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostChangeTwoStepAuthentication({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostRemoveTwoStepAuthenticationEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postRemoveTwoStepAuthentication; + const { metricsEnabled } = opts; + const links = { + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostRemoveTwoStepAuthentication({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostNewRecoveryCodesEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postNewRecoveryCodes; + const { metricsEnabled } = opts; + const links = { + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + link: this.linkBuilder.buildPostNewRecoveryCodesLink(), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostNewRecoveryCodes({ + ...opts, + ...links, + }); return this.emailSender.send({ to: opts.to, cc: opts.cc, @@ -129,44 +450,465 @@ export class FxaMailer extends FxaEmailRenderer { }); } - /** - * Renders and sends the password forgot OTP email. - * @param opts - * @returns - */ - async sendPasswordForgotOtpEmail( + async sendPostConsumeRecoveryCodeEmail( opts: EmailSenderOpts & EmailFlowParams & - OmitCommonLinks> + OmitCommonLinks & + OmitCommonLinks ) { - const { template: name, version } = passwordForgotOtp; + const { template, version } = postConsumeRecoveryCode; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + twoFactorSettingsLink: this.linkBuilder.buildTwoFactorSettignsLink(), + link: this.linkBuilder.buildPrimaryLink(template, metricsEnabled, opts), + resetLink: this.linkBuilder.buildResetLink( + template, + opts.to, + metricsEnabled + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostConsumeRecoveryCode({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } - const { privacyUrl, supportUrl } = this.linkBuilder.buildCommonLinks( - name, - opts.metricsEnabled + async sendLowRecoveryCodesEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = lowRecoveryCodes; + const { metricsEnabled } = opts; + const links = { + ...this.linkBuilder.buildCommonLinks(template, metricsEnabled), + link: this.linkBuilder.buildPrimaryLink(template, metricsEnabled, opts), + resetLink: this.linkBuilder.buildResetLink( + template, + opts.to, + metricsEnabled + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts ); + const rendered = await this.renderLowRecoveryCodes({ + ...opts, + ...links, + }); + return this.emailSender.send({ + to: opts.to, + cc: opts.cc, + from: this.mailerConfig.sender, + headers, + ...rendered, + }); + } - const rendered = await this.renderPasswordForgotOtp({ + async sendPostSigninRecoveryCodeEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postSigninRecoveryCode; + const { metricsEnabled } = opts; + const links = { + ...this.linkBuilder.buildCommonLinks(template, opts.metricsEnabled), + resetLink: this.linkBuilder.buildResetLink( + template, + opts.to, + metricsEnabled + ), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostSigninRecoveryCode({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostAddRecoveryPhoneEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postAddRecoveryPhone; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + twoFactorSupportLink: this.linkBuilder.buildTwoFactorSupportLink(), + resetLink: this.linkBuilder.buildResetLink( + template, + opts.to, + metricsEnabled + ), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostAddRecoveryPhone({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostChangeRecoveryPhoneEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postChangeRecoveryPhone; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + resetLink: this.linkBuilder.buildResetLink( + template, + opts.to, + metricsEnabled + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.resetLink }, + opts + ); + const rendered = await this.renderPostChangeRecoveryPhone({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostRemoveRecoveryPhoneEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postRemoveRecoveryPhone; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + resetLink: this.linkBuilder.buildResetLink( + template, + opts.to, + metricsEnabled + ), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostRemoveRecoveryPhone({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPasswordResetRecoveryPhoneEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = passwordResetRecoveryPhone; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + resetLink: this.linkBuilder.buildResetLink( + template, + opts.to, + metricsEnabled + ), + twoFactorSettingsLink: this.linkBuilder.buildTwoFactorSettignsLink(), + link: this.linkBuilder.buildPrimaryLink(template, metricsEnabled, opts), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPasswordResetRecoveryPhone({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostSigninRecoveryPhoneEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postSigninRecoveryPhone; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + resetLink: this.linkBuilder.buildResetLink( + template, + opts.to, + metricsEnabled + ), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostSigninRecoveryPhone({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostAddAccountRecoveryEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postAddAccountRecovery; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + revokeAccountRecoveryLink: + this.linkBuilder.buildRevokeAccountRecoveryLink(), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostAddAccountRecovery({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPostChangeAccountRecoveryEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postChangeAccountRecovery; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + revokeAccountRecoveryLink: + this.linkBuilder.buildRevokeAccountRecoveryLink(), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostChangeAccountRecovery({ ...opts, - supportUrl, - privacyUrl, + ...links, }); + return this.sendEmail(opts, headers, rendered); + } - const headers = this.emailSender.buildHeaders({ - context: { ...opts, serverName: SERVER, language: opts.acceptLanguage }, - headers: { - 'x-password-forgot-otp': opts.code, + async sendPostRemoveAccountRecoveryEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = postRemoveAccountRecovery; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + revokeAccountRecoveryLink: + this.linkBuilder.buildRevokeAccountRecoveryLink(), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPostRemoveAccountRecovery({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPasswordResetAccountRecoveryEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = passwordResetAccountRecovery; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeRequiredLink( + this.linkBuilder.urls.initiatePasswordReset, // TODO: Check that this is the right base url + opts.to, + opts.metricsEnabled + ), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPasswordResetAccountRecovery({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + async sendPasswordResetWithRecoveryKeyPromptEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template, version } = passwordResetWithRecoveryKeyPrompt; + const { metricsEnabled } = opts; + const links = { + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + passwordChangeLink: this.linkBuilder.buildPasswordChangeRequiredLink( + this.linkBuilder.urls.initiatePasswordReset, // TODO: Double check this is the right url + opts.to, + opts.metricsEnabled + ), + link: this.linkBuilder.buildPrimaryLink( + template, + opts.metricsEnabled, + opts + ), + }; + const headers = this.buildHeaders( + { template, version }, + { 'X-Link': links.link }, + opts + ); + const rendered = await this.renderPasswordResetWithRecoveryKeyPrompt({ + ...opts, + ...links, + }); + return this.sendEmail(opts, headers, rendered); + } + + private buildHeaders( + template: { template: string; version: number }, + headers: Record, + opts: { acceptLanguage: string } + ) { + return this.emailSender.buildHeaders({ + context: { + ...opts, + serverName: SERVER, + language: opts.acceptLanguage, }, + headers, template: { - name, - version, + name: template.template, + version: template.version, }, }); + } + + private async sendEmail( + opts: { to: string; cc?: string[]; cmsRpFromName?: string }, + headers: Record, + rendered: RenderedTemplate + ) { + const { cmsRpFromName, to, cc } = opts; + const from = cmsRpFromName + ? `${cmsRpFromName} <${this.mailerConfig.sender}>` + : this.mailerConfig.sender; return this.emailSender.send({ - to: opts.to, - cc: opts.cc, - from: this.mailerConfig.sender, + to, + cc, + from, headers, ...rendered, }); diff --git a/packages/fxa-auth-server/lib/types.ts b/packages/fxa-auth-server/lib/types.ts index 0fda7ed2a14..951998518c2 100644 --- a/packages/fxa-auth-server/lib/types.ts +++ b/packages/fxa-auth-server/lib/types.ts @@ -115,6 +115,12 @@ export interface AuthLogger extends Logger { ): Promise; } +export interface AuthClientInfoService { + fetch(service: string): Promise<{ + name: string; + }>; +} + // Container token types // eslint-disable-next-line @typescript-eslint/no-redeclare export const AuthLogger = new Token('AUTH_LOGGER'); diff --git a/packages/fxa-shared/lib/user-agent.ts b/packages/fxa-shared/lib/user-agent.ts index 1216bc5dee7..58c7d3c39d5 100644 --- a/packages/fxa-shared/lib/user-agent.ts +++ b/packages/fxa-shared/lib/user-agent.ts @@ -38,6 +38,7 @@ export type UAScalarProperties = { // @ts-ignore import * as ua from 'node-uap'; +import { Account } from '../db/models'; // We know this won't match "Symbian^3", "UI/WKWebView" or "Mail.ru" but // it's simpler and safer to limit to alphanumerics, underscore and space. @@ -217,6 +218,17 @@ function marshallDeviceType(formFactor: string) { return 'mobile'; } +export function formatAccountEmails(account: Account) { + return { + to: + account.emails?.find((e) => e.isPrimary && e.isVerified)?.email || + account.email, + cc: account.emails + ?.filter((e) => !e.isPrimary && e.isVerified) + .map((e) => e.email), + }; +} + /** * Format user agent info safely for email templates. * Returns undefined if no valid browser or OS info is available. @@ -225,23 +237,29 @@ function marshallDeviceType(formFactor: string) { * @param uaOS - OS name from user agent * @param uaOSVersion - OS version from user agent */ -export const formatUserAgentInfo = ( - uaBrowser?: string, - uaOS?: string, - uaOSVersion?: string -): - | { - uaBrowser: string; - uaOS: string; - uaOSVersion: string; - } - | undefined => { - const safeBrowser = safeReturnName(uaBrowser || ''); - const safeOS = safeReturnName(uaOS || ''); - const safeOSVersion = safeReturnVersion(uaOSVersion || ''); +export const formatUserAgentInfo = ({ + browser, + os, + osVersion, +}: { + browser?: string; + os?: string; + osVersion?: string; +}): { + uaBrowser: string; + uaOS: string; + uaOSVersion: string; +} => { + const safeBrowser = safeReturnName(browser || ''); + const safeOS = safeReturnName(os || ''); + const safeOSVersion = safeReturnVersion(osVersion || ''); return !safeBrowser && !safeOS - ? undefined + ? { + uaBrowser: '', + uaOS: '', + uaOSVersion: '', + } : { uaBrowser: safeBrowser || '', uaOS: safeOS || '', From 916707038cb8a7c8e0bb8e4738d8955b7c59b116 Mon Sep 17 00:00:00 2001 From: dschom Date: Fri, 23 Jan 2026 19:06:24 -0800 Subject: [PATCH 2/3] bug(many): Fix broken jest ui test runner --- .vscode/extensions.json | 3 +-- .vscode/settings.json | 7 +++---- libs/shared/react/.swcrc | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 libs/shared/react/.swcrc diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8a3bee5b4cb..9dfcd653046 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -7,7 +7,6 @@ "bierner.github-markdown-preview", "alex-young.pm2-explorer", "dannycoates.pm2-node-debugger", - "firefox-devtools.vscode-firefox-debug", - "firsttris.vscode-jest-runner" + "firefox-devtools.vscode-firefox-debug" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 1061010abae..ca389b14dc0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,8 +17,7 @@ "playwright.env": { "NODE_OPTIONS": "--dns-result-order=ipv4first" }, - "editor.codeActionsOnSave": { - }, + "editor.codeActionsOnSave": {}, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, @@ -35,7 +34,7 @@ "**/node_modules": true, "**/tmp": true, "**/temp": true, - "*.sass-cache": true, + "*.sass-cache": true }, - "cSpell.words": ["Frontends"] + "jest.jestCommandLine": "node_modules/.bin/jest --config jest.config.ts" } diff --git a/libs/shared/react/.swcrc b/libs/shared/react/.swcrc new file mode 100644 index 00000000000..f52b4e44979 --- /dev/null +++ b/libs/shared/react/.swcrc @@ -0,0 +1,14 @@ +{ + "jsc": { + "target": "es2017", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "decoratorMetadata": true, + "legacyDecorator": true + } + } +} From 7f5b29c1dec8eb2f5851a561cc98d7c921ec959e Mon Sep 17 00:00:00 2001 From: dschom Date: Fri, 23 Jan 2026 19:18:49 -0800 Subject: [PATCH 3/3] wip - more link building updates --- .../fxa-email-renderer.spec.ts.snap | 840 ++++++++++++++++++ .../src/renderer/email-link-builder.spec.ts | 312 +++++-- .../src/renderer/email-link-builder.ts | 276 +++--- .../src/renderer/fxa-email-renderer.spec.ts | 33 + .../src/backend/email.service.ts | 13 +- packages/fxa-admin-server/src/config/index.ts | 24 +- packages/fxa-auth-server/config/index.ts | 21 + .../fxa-auth-server/lib/senders/fxa-mailer.ts | 271 +++--- 8 files changed, 1475 insertions(+), 315 deletions(-) diff --git a/libs/accounts/email-renderer/src/renderer/__snapshots__/fxa-email-renderer.spec.ts.snap b/libs/accounts/email-renderer/src/renderer/__snapshots__/fxa-email-renderer.spec.ts.snap index 52502ddea3c..ef7561edd09 100644 --- a/libs/accounts/email-renderer/src/renderer/__snapshots__/fxa-email-renderer.spec.ts.snap +++ b/libs/accounts/email-renderer/src/renderer/__snapshots__/fxa-email-renderer.spec.ts.snap @@ -7620,6 +7620,846 @@ For more information, please visit + Two-step authentication is on + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Your account is protected
+ + +
+ + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
You turned on two-step authentication
+ +
+ +
Security codes from your authenticator app are now required every time you sign in.
+ +
+ +
You also added backup authentication codes as your recovery method.
+ +
+ + + +
+ +
You requested this from:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
Firefox on Windows 100 + + + + +
+ + + + + + + + San Francisco, CA, US (estimated) + + + +
+ + + Jan 1, 2024 +
+ + + 12:00 PM +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Manage account + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + +
+ + + + " +`; + +exports[`FxA Email Renderer should render renderPostAddTwoStepAuthentication with a recovery phone messaging: matches full email snapshot 1`] = ` +" + Two-step authentication is on + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Your account is protected
+ + +
+ + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
You turned on two-step authentication
+ +
+ +
Security codes from your authenticator app are now required every time you sign in.
+ +
+ +
You also added *******123 as your recovery phone number.
+ +
+ + + +
+ +
You requested this from:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
Firefox on Windows 100 + + + + +
+ + + + + + + + San Francisco, CA, US (estimated) + + + +
+ + + Jan 1, 2024 +
+ + + 12:00 PM +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Manage account + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + +
+ + + " `; diff --git a/libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts b/libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts index aee6846f418..8a791add992 100644 --- a/libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts +++ b/libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts @@ -18,6 +18,20 @@ describe('EmailLinkBuilder', () => { enabled: true, subdomain: 'test', }, + + baseUri: 'http://localhost:30303', + androidUrl: + 'https://app.adjust.com/2uo1qc?campaign=fxa-conf-email&adgroup=android&creative=button&utm_source=email', + iosUrl: + 'https://app.adjust.com/2uo1qc?campaign=fxa-conf-email&adgroup=ios&creative=button&fallback=https%3A%2F%2Fitunes.apple.com%2Fapp%2Fapple-store%2Fid989804926%3Fpt%3D373246%26ct%3Dadjust_tracker%26mt%3D8&utm_source=email', + mozillaSupportUrl: 'https://support.mozilla.org', + twoFactorSupportUrl: + 'https://privacyportal.onetrust.com/webform/1350748f-7139-405c-8188-22740b3b5587/4ba08202-2ede-4934-a89e-f0b0870f95f0', + subscriptionSupportUrl: 'https://support.mozilla.org/products', + subscriptionTermsUrl: + 'https://www.mozilla.org/about/legal/terms/subscription-services', + defaultSurveyUrl: + 'https://survey.alchemer.com/s3/6534408/Privacy-Security-Product-Cancellation-of-Service-Q4-21', }; let linkBuilder: EmailLinkBuilder; @@ -26,92 +40,268 @@ describe('EmailLinkBuilder', () => { linkBuilder = new EmailLinkBuilder(mockConfig); }); - describe('buildCommonLinks', () => { - it('should build privacy and support links with UTM params', () => { - const templateName = 'recovery'; + describe('buildPasswordChangeRequiredLink', () => { + it('should build password change required link with params', () => { + const link = linkBuilder.buildPasswordChangeRequiredLink( + 'passwordChangeRequired', + true, + { email: 'foo@mozilla.com' } + ); - const links = linkBuilder.buildCommonLinks(templateName, true); + // There will be no utm_campaign because, passwordChangeRequired has no campaign mapping. + expect(link).toEqual( + 'http://localhost:30303/settings/change_password?utm_medium=email&utm_content=fx-change-password&email=foo%40mozilla.com' + ); + }); - expect(links.privacyUrl).toContain('http://localhost:3030/privacy'); - expect(links.privacyUrl).toContain('utm_medium=email'); - expect(links.privacyUrl).toContain('utm_campaign=fx-forgot-password'); - expect(links.privacyUrl).toContain('utm_content=fx-privacy'); + it('should build password change required link without utml params', () => { + const link = linkBuilder.buildPasswordChangeRequiredLink( + 'passwordChangeRequired', + false, + { email: 'foo@mozilla.com' } + ); - expect(links.supportUrl).toContain('http://localhost:3030/support'); - expect(links.supportUrl).toContain('utm_medium=email'); - expect(links.supportUrl).toContain('utm_campaign=fx-forgot-password'); - expect(links.supportUrl).toContain('utm_content=fx-support'); + // There will be no utm_campaign because, passwordChangeRequired has no campaign mapping. + expect(link).toEqual( + 'http://localhost:30303/settings/change_password?email=foo%40mozilla.com' + ); }); }); - describe('buildLinkWithQueryParamsAndUTM', () => { - it('should add query params and UTM params to link', () => { - const link = linkBuilder.buildLinkWithQueryParamsAndUTM( - 'http://localhost:3030/some-page', - 'recovery', - { - uid: '12345', - token: 'abc123', - email: 'test@example.com', - }, - true + describe('buildPasswordChangeLink', () => { + it('can build link', () => { + const link = linkBuilder.buildPasswordChangeLink('recovery', true, { + email: 'foo@mozilla.com', + }); + expect(link).toEqual( + 'http://localhost:30303/settings/change_password?utm_medium=email&utm_campaign=fx-forgot-password&utm_content=fx-change-password&email=foo%40mozilla.com' ); - - const url = new URL(link); - expect(url.searchParams.get('uid')).toBe('12345'); - expect(url.searchParams.get('token')).toBe('abc123'); - expect(url.searchParams.get('email')).toBe('test@example.com'); - expect(url.searchParams.get('utm_medium')).toBe('email'); - expect(url.searchParams.get('utm_campaign')).toBe('fx-forgot-password'); }); - it('should handle empty query params', () => { - const templateName = 'recovery'; - const queryParams = {}; - - const link = linkBuilder.buildLinkWithQueryParamsAndUTM( - 'http://localhost:3030/some-page', - templateName, - queryParams, - true + it('can build without utm', () => { + const link = linkBuilder.buildPasswordChangeLink('recovery', false, { + email: 'foo@mozilla.com', + }); + expect(link).toEqual( + 'http://localhost:30303/settings/change_password?email=foo%40mozilla.com' ); + }); + }); - const url = new URL(link); - expect(url.searchParams.get('utm_medium')).toBe('email'); - expect(url.searchParams.get('utm_campaign')).toBe('fx-forgot-password'); + describe('buildRevokeAccountRecoveryLink', () => { + it('can build link', () => { + const link = linkBuilder.buildRevokeAccountRecoveryLink('recovery', true); + expect(link).toEqual( + 'http://localhost:30303/settings?utm_medium=email&utm_campaign=fx-forgot-password&utm_content=fx-report#recovery-key' + ); }); - it('should respect metricsEnabled flag', () => { - const link = linkBuilder.buildLinkWithQueryParamsAndUTM( - 'http://localhost:3030/some-page', + it('can build without utm', () => { + const link = linkBuilder.buildRevokeAccountRecoveryLink( 'recovery', - {}, false ); + expect(link).toEqual('http://localhost:30303/settings#recovery-key'); + }); + }); - const url = new URL(link); - expect(url.searchParams.get('utm_medium')).toBeNull(); - expect(url.searchParams.get('utm_campaign')).toBeNull(); + describe('buildLowRecoveryCodesLink', () => { + it('can build link', () => { + const link = linkBuilder.buildLowRecoveryCodesLink( + 'lowRecoveryCodes', + true, + { + email: 'foo@mozilla.com', + uid: '123', + } + ); + expect(link).toEqual( + 'http://localhost:30303/settings/two_step_authentication/replace_codes?utm_medium=email&utm_campaign=fx-low-recovery-codes&utm_content=fx-low-recovery-codes&low_recovery_codes=true&email=foo%40mozilla.com&uid=123' + ); + }); + + it('can build without utm', () => { + const link = linkBuilder.buildLowRecoveryCodesLink( + 'lowRecoveryCodes', + false, + { + email: 'foo@mozilla.com', + uid: '123', + } + ); + expect(link).toEqual( + 'http://localhost:30303/settings/two_step_authentication/replace_codes?low_recovery_codes=true&email=foo%40mozilla.com&uid=123' + ); }); }); + describe('buildPostNewRecoveryCodesLink', () => { + it('can build link', () => { + const link = linkBuilder.buildPostNewRecoveryCodesLink( + 'postNewRecoveryCodes', + true, + { + email: 'foo@mozilla.com', + uid: '123', + } + ); + expect(link).toEqual( + 'http://localhost:30303/settings?utm_medium=email&utm_campaign=fx-account-replace-recovery-codes&utm_content=fx-account-replace-recovery-codes&email=foo%40mozilla.com&uid=123' + ); + }); - describe('buildPasswordChangeRequiredLink', () => { - it('should build password change required link with params', () => { - const opts = { - url: 'http://localhost:3030/reset_password', - email: 'test@example.com', - }; + it('can build without utm', () => { + const link = linkBuilder.buildPostNewRecoveryCodesLink( + 'postNewRecoveryCodes', + false, + { + email: 'foo@mozilla.com', + uid: '123', + } + ); + expect(link).toEqual( + 'http://localhost:30303/settings?email=foo%40mozilla.com&uid=123' + ); + }); + }); + describe('buildTwoFactorSettignsLink', () => { + it('can build link', () => { + const link = linkBuilder.buildTwoFactorSettignsLink( + 'postConsumeRecoveryCode', + true, + { + email: 'foo@mozilla.com', + } + ); + expect(link).toEqual( + 'http://localhost:30303/settings?utm_medium=email&utm_campaign=fx-account-consume-recovery-code&utm_content=fx-manage-two-factor&email=foo%40mozilla.com#two-step-authentication' + ); + }); - const link = linkBuilder.buildPasswordChangeRequiredLink( - opts.url, - opts.email, + it('can build without utm', () => { + const link = linkBuilder.buildTwoFactorSettignsLink( + 'postConsumeRecoveryCode', + false, + { + email: 'foo@mozilla.com', + } + ); + expect(link).toEqual( + 'http://localhost:30303/settings?email=foo%40mozilla.com#two-step-authentication' + ); + }); + }); + describe('buildTwoFactorSupportLink', () => { + it('can build link', () => { + const link = linkBuilder.buildTwoFactorSupportLink(); + expect(link).toEqual(mockConfig.twoFactorSupportUrl); + }); + }); + describe('buildAndroidLink', () => { + it('can build link', () => { + const link = linkBuilder.buildAndroidLink(); + expect(link).toEqual(mockConfig.androidUrl); + }); + }); + describe('buildIosLink', () => { + it('can build link', () => { + const link = linkBuilder.buildIosLink(); + expect(link).toEqual(mockConfig.iosUrl); + }); + }); + + describe('buildTermsOfServiceDownloadLink', () => { + it('can build link', () => { + const link = linkBuilder.buildTermsOfServiceDownloadLink( + 'downloadSubscription', true ); + expect(link).toEqual( + `${mockConfig.subscriptionTermsUrl}?utm_medium=email&utm_content=fx-subscription-terms` + ); + }); + it('can build link without utm', () => { + const link = linkBuilder.buildTermsOfServiceDownloadLink( + 'downloadSubscription', + false + ); + expect(link).toEqual(`${mockConfig.subscriptionTermsUrl}`); + }); + }); - expect(link).toContain('http://localhost:3030/reset_password'); - expect(link).toContain('email=test%40example.com'); + describe('buildPrivacyLink', () => { + it('can build', () => { + const link = linkBuilder.buildPrivacyLink('recovery', true); + expect(link).toEqual( + 'http://localhost:3030/privacy?utm_medium=email&utm_campaign=fx-forgot-password&utm_content=fx-privacy' + ); + }); + + it('can build without utm', () => { + const link = linkBuilder.buildPrivacyLink('recovery', false); + expect(link).toEqual('http://localhost:3030/privacy'); + }); + }); + + describe('buildSupportLink', () => { + it('can build', () => { + const link = linkBuilder.buildSupportLink('recovery', true); + expect(link).toContain('http://localhost:3030/support'); expect(link).toContain('utm_medium=email'); - expect(link).toContain('utm_campaign=fx-password-reset-required'); + expect(link).toContain('utm_campaign=fx-forgot-password'); + expect(link).toContain('utm_content=fx-support'); + }); + }); + + describe('buildRecoveryLink', () => { + it('can build', () => { + const link = linkBuilder.buildRecoveryLink('recovery', true, { + code: '1234', + email: 'foo@mozilla.com', + emailToHashWith: 'foo@mozilla.com', + redirectTo: 'https://mozilla.org', + resume: '111', + service: 'sync', + token: '222', + uid: '123', + }); + + expect(link).toEqual( + 'http://localhost:30303/complete_reset_password?utm_medium=email&utm_campaign=fx-forgot-password&utm_content=fx-reset-password&code=1234&email=foo%40mozilla.com&emailToHashWith=foo%40mozilla.com&redirectTo=https%3A%2F%2Fmozilla.org&resume=111&service=sync&token=222&uid=123' + ); + }); + + it('can build without utm', () => { + const link = linkBuilder.buildRecoveryLink('recovery', false, { + code: '1234', + email: 'foo@mozilla.com', + emailToHashWith: 'foo@mozilla.com', + redirectTo: 'https://mozilla.org', + resume: '111', + service: 'sync', + token: '222', + uid: '123', + }); + + expect(link).toEqual( + 'http://localhost:30303/complete_reset_password?code=1234&email=foo%40mozilla.com&emailToHashWith=foo%40mozilla.com&redirectTo=https%3A%2F%2Fmozilla.org&resume=111&service=sync&token=222&uid=123' + ); + }); + }); + + describe('buildMozillaSupportUrl', () => { + it('can build', () => { + const link = linkBuilder.buildMozillaSupportUrl(); + + expect(link).toEqual(mockConfig.mozillaSupportUrl); + }); + }); + + describe('buildLinkAttributes', () => { + it('builds', () => { + const attrs = linkBuilder.buildLinkAttributes('http://mozilla.org'); + expect(attrs).toEqual( + `href="http://mozilla.org" style="color: #0a84ff; text-decoration: none; font-family: sans-serif;"` + ); }); }); }); 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 a194dd64135..af22e09cefd 100644 --- a/libs/accounts/email-renderer/src/renderer/email-link-builder.ts +++ b/libs/accounts/email-renderer/src/renderer/email-link-builder.ts @@ -81,17 +81,23 @@ const TEMPLATE_NAME_TO_CONTENT_MAP: Record = { export interface EmailLinkBuilderConfig { metricsEnabled: boolean; - initiatePasswordResetUrl: string; - passwordResetUrl: string; - privacyUrl: string; - supportUrl: string; - accountSettingsUrl: string; - verificationUrl: string; - verifyLoginUrl: string; prependVerificationSubdomain: { enabled: boolean; subdomain: string; }; + + /** Url for the front end. e.g. https://accounts.firefox.com */ + baseUri: string; + + defaultSurveyUrl: string; + subscriptionTermsUrl: string; + androidUrl: string; + iosUrl: string; + supportUrl: string; + subscriptionSupportUrl: string; + privacyUrl: string; + twoFactorSupportUrl: string; + mozillaSupportUrl: string; } export type RecoveryLinkQueryParams = { @@ -114,15 +120,15 @@ export class EmailLinkBuilder { */ public get urls() { return { - initiatePasswordReset: this.config.initiatePasswordResetUrl, - completePasswordReset: this.config.passwordResetUrl, privacy: this.config.privacyUrl, support: this.config.supportUrl, - accountSettings: this.config.accountSettingsUrl, - verificationUrl: this.config.verificationUrl, }; } + private get baseUri() { + return this.config.baseUri; + } + /** * Adds UTM parameters to the provided link if metrics are enabled. * @param link - URL object to add parameters to @@ -131,7 +137,7 @@ export class EmailLinkBuilder { * @param content - Optional content override (defaults to template's content map value) */ private addUTMParams( - link: URL, + url: URL, templateName: string, metricsEnabled: boolean, content?: string @@ -142,70 +148,128 @@ export class EmailLinkBuilder { return; } - link.searchParams.set('utm_medium', 'email'); + const hash = url.hash; + + url.searchParams.set('utm_medium', 'email'); const campaign = this.getCampaign(templateName); - if (campaign && !link.searchParams.has('utm_campaign')) { - link.searchParams.set('utm_campaign', campaign); + if (campaign && !url.searchParams.has('utm_campaign')) { + url.searchParams.set('utm_campaign', campaign); } const contentValue = content || this.getContent(templateName); if (contentValue) { - link.searchParams.set('utm_content', UTM_PREFIX + contentValue); + url.searchParams.set('utm_content', UTM_PREFIX + contentValue); } - } - buildPasswordChangeLink(): string { - throw new Error('TBD'); + url.hash = hash; // restore hash! } - buildRevokeAccountRecoveryLink(): string { - throw new Error('TBD'); - } + private addQueryParams(url: URL, query: Record) { + const hash = url.hash; + Object.entries(query).forEach(([k, v]) => { + if (v) { + url.searchParams.set(k, v); + } + }); - buildLowRecoveryCodesLink(): string { - throw new Error('TBD'); + if (hash) { + url.hash = hash; + } } - buildPostNewRecoveryCodesLink(): string { - throw new Error('TBD'); + buildPasswordChangeLink( + templateName: string, + metricsEnabled: boolean, + query: { + email: string; + } + ): string { + const url = new URL(`${this.baseUri}/settings/change_password`); + this.addUTMParams(url, templateName, metricsEnabled, 'change-password'); + this.addQueryParams(url, query); + return url.toString(); } - buildTwoFactorSettignsLink(): string { - throw new Error('TBD'); + buildRevokeAccountRecoveryLink( + templateName: string, + metricsEnabled: boolean + ): string { + const url = new URL(`${this.baseUri}/settings#recovery-key`); + this.addUTMParams(url, templateName, metricsEnabled, 'report'); + return url.toString(); } - buildTwoFactorSupportLink(): string { - throw new Error('TBD'); + buildLowRecoveryCodesLink( + templateName: string, + metricsEnabled: boolean, + query: { + email: string; + uid: string; + } + ): string { + const url = new URL( + `${this.baseUri}/settings/two_step_authentication/replace_codes` + ); + this.addUTMParams(url, templateName, metricsEnabled); + this.addQueryParams(url, { + low_recovery_codes: 'true', + ...query, + }); + + return url.toString(); } - buildAndroidLink(): string { - throw new Error('TBD'); + buildPostNewRecoveryCodesLink( + templateName: string, + metricsEnabled: boolean, + query: { + email: string; + uid: string; + } + ): string { + const url = new URL(`${this.baseUri}/settings`); + this.addUTMParams(url, templateName, metricsEnabled); + this.addQueryParams(url, query); + return url.toString(); } - buildIosLink(): string { - throw new Error('TBD'); + buildTwoFactorSettignsLink( + templateName: string, + metricsEnabled: boolean, + query: { + email: string; + } + ): string { + const url = new URL(`${this.baseUri}/settings#two-step-authentication`); + this.addUTMParams(url, templateName, metricsEnabled, 'manage-two-factor'); + this.addQueryParams(url, query); + return url.toString(); } - buildTermsOfServiceDownloadLink(opts: { metricsEnabled: boolean }) { - throw new Error('TBD'); + buildTwoFactorSupportLink(): string { + return this.config.twoFactorSupportUrl; } - buildDefaultSurveyLink() { - throw new Error('TBD'); - // const defaultSureyUrl = 'https://survey.alchemer.com/s3/6534408/Privacy-Security-Product-Cancellation-of-Service-Q4-21'; + buildAndroidLink(): string { + return this.config.androidUrl; } - buildPrivacyNoticeDownloadLink() { - throw new Error('TBD'); + buildIosLink(): string { + return this.config.iosUrl; } - buildCancellationSurveyLink() { - throw new Error('TBD'); + buildTermsOfServiceDownloadLink( + templateName: string, + metricsEnabled: boolean + ) { + const url = new URL(this.config.subscriptionTermsUrl); + this.addUTMParams(url, templateName, metricsEnabled, 'subscription-terms'); + return url.toString(); } buildPrivacyLink(templateName: string, metricsEnabled: boolean) { - const privacyUrl = new URL(this.urls.privacy); + const privacyUrl = new URL(this.config.privacyUrl); this.addUTMParams(privacyUrl, templateName, metricsEnabled, 'privacy'); return privacyUrl.toString(); } @@ -219,69 +283,26 @@ export class EmailLinkBuilder { buildRecoveryLink( templateName: 'recovery', metricsEnabled: boolean, - queryParams: RecoveryLinkQueryParams + query: RecoveryLinkQueryParams ): string { - const url = new URL(this.urls.completePasswordReset); + const url = new URL(`${this.baseUri}/complete_reset_password`); this.addUTMParams(url, templateName, metricsEnabled); - Object.entries(queryParams).forEach((x) => { - const [k, v] = x; - if (v) { - url.searchParams.set(k, v); - } - }); + this.addQueryParams(url, query); return url.toString(); } - buildMozillaSupportUrl(templateName: string, metricsEnabled: boolean) { - const mozillaSupportUrl = new URL('https://support.mozilla.org'); - this.addUTMParams(mozillaSupportUrl, templateName, metricsEnabled); - return mozillaSupportUrl.toString(); + buildMozillaSupportUrl() { + return this.config.mozillaSupportUrl; } - /** - * Deprecated - Build links one at a time suing buildLink calls - * Build common links with UTM parameters (privacy, support) - * @param templateName - * @param metricsEnabled - Inidicates if metrics/tracking is enabled for the user - * @returns Object containing privacyUrl and supportUrl as strings - */ - buildCommonLinks(templateName: string, metricsEnabled: boolean) { - return { - privacyUrl: this.buildPrivacyLink(templateName, metricsEnabled), - supportUrl: this.buildSupportLink(templateName, metricsEnabled), - mozillaSupportUrl: this.buildMozillaSupportUrl( - templateName, - metricsEnabled - ), - }; - } - - buildPrimaryLink( + buildAccountSettingsLink( templateName: string, metricsEnabled: boolean, - opts: { - to: string; - uid: string; - }, - primaryLink?: string - ): string { - // Create the URL and fill out query params - const url = new URL(primaryLink || this.urls.accountSettings); - - url.searchParams.set('email', opts.to); - url.searchParams.set('uid', opts.uid); - + query: { email?: string; uid?: string } + ) { + const url = new URL(`${this.baseUri}/settings`); this.addUTMParams(url, templateName, metricsEnabled); - - // Special case for verification subdomains. Locally these are disabled, but in - // other environmetns this will likely kick in! - if ( - primaryLink === this.config.verificationUrl || - primaryLink === this.config.verifyLoginUrl - ) { - url.host = `${this.config.prependVerificationSubdomain.subdomain}.${url.host}`; - } - + this.addQueryParams(url, query); return url.toString(); } @@ -310,46 +331,22 @@ export class EmailLinkBuilder { return TEMPLATE_NAME_TO_CONTENT_MAP[templateName] || ''; } - /** - * Adds query parameters to the provided link; includes UTM parameters if metrics are enabled. - * @param link - Base link string - * @param templateName - Email template name (used to lookup campaign) - * @param opts - Key/value pairs to add as query parameters - * @param metricsEnabled - Inidicates if metrics/tracking is enabled for the user - * @returns Link string with query parameters and UTM parameters (if enabled) - */ - buildLinkWithQueryParamsAndUTM( - link: string, - templateName: string, - opts: Record, - metricsEnabled: boolean, - content?: string - ): string { - const url = new URL(link); - - for (const [key, value] of Object.entries(opts)) { - if (value !== undefined) { - url.searchParams.set(key, value); - } - } - this.addUTMParams(url, templateName, metricsEnabled, content); - return url.toString(); - } - /** * Builds a password change required link with email and UTM parameters. * @param opts * @returns Link to for changing user's password */ buildPasswordChangeRequiredLink( - url: string, - email: string, - metricsEnabled: boolean + templateName: string, + metricsEnabled: boolean, + query: { + email: string; + } ): string { - const link = new URL(url); - this.addUTMParams(link, 'passwordResetRequired', metricsEnabled); - link.searchParams.set('email', email); - return link.toString(); + const url = new URL(`${this.baseUri}/settings/change_password`); + this.addUTMParams(url, templateName, metricsEnabled, 'change-password'); + this.addQueryParams(url, query); + return url.toString(); } /** @@ -360,18 +357,15 @@ export class EmailLinkBuilder { */ buildResetLink( templateName: string, - email: string, - metricsEnabled: boolean + metricsEnabled: boolean, + query: { + email: string; + } ): string { - return this.buildLinkWithQueryParamsAndUTM( - this.urls.initiatePasswordReset, - templateName, - { - email, - }, - metricsEnabled, - 'reset-password' - ); + const url = new URL(`${this.baseUri}/reset_password`); + this.addUTMParams(url, templateName, metricsEnabled, 'reset-password'); + this.addQueryParams(url, query); + return url.toString(); } } diff --git a/libs/accounts/email-renderer/src/renderer/fxa-email-renderer.spec.ts b/libs/accounts/email-renderer/src/renderer/fxa-email-renderer.spec.ts index 90ed2b3746d..3792458a363 100644 --- a/libs/accounts/email-renderer/src/renderer/fxa-email-renderer.spec.ts +++ b/libs/accounts/email-renderer/src/renderer/fxa-email-renderer.spec.ts @@ -306,6 +306,39 @@ describe('FxA Email Renderer', () => { }); it('should render renderPostAddTwoStepAuthentication', async () => { + const email = await renderer.renderPostAddTwoStepAuthentication({ + date: 'Jan 1, 2024', + device: mockDevice, + location: mockLocation, + link: mockLink, + time: '12:00 PM', + twoFactorSupportLink: mockLinkSupport, + passwordChangeLink: mockLinkPasswordChange, + ...defaultLayoutTemplateValues, + recoveryMethod: '', // Won't render phone or recovery codes section. + }); + expect(email).toBeDefined(); + expect(email.html).toMatchSnapshot('matches full email snapshot'); + }); + + it('should render renderPostAddTwoStepAuthentication with a recovery phone messaging', async () => { + const email = await renderer.renderPostAddTwoStepAuthentication({ + date: 'Jan 1, 2024', + device: mockDevice, + location: mockLocation, + link: mockLink, + time: '12:00 PM', + twoFactorSupportLink: mockLinkSupport, + passwordChangeLink: mockLinkPasswordChange, + ...defaultLayoutTemplateValues, + recoveryMethod: 'phone', + maskedPhoneNumber: '*******123', + }); + expect(email).toBeDefined(); + expect(email.html).toMatchSnapshot('matches full email snapshot'); + }); + + it('should render renderPostAddTwoStepAuthentication with a recovery codes messaging', async () => { const email = await renderer.renderPostAddTwoStepAuthentication({ date: 'Jan 1, 2024', device: mockDevice, diff --git a/packages/fxa-admin-server/src/backend/email.service.ts b/packages/fxa-admin-server/src/backend/email.service.ts index 28774fbc276..551fd5da5a9 100644 --- a/packages/fxa-admin-server/src/backend/email.service.ts +++ b/packages/fxa-admin-server/src/backend/email.service.ts @@ -69,13 +69,14 @@ export class EmailService { public async sendPasswordChangeRequired(account: Account) { const smtpConfig = this.smtpConfig; - const linksConfig = this.linksConfig; // Render the email const link = this.linkBuilder.buildPasswordChangeRequiredLink( - linksConfig.initiatePasswordResetUrl, - account.primaryEmail?.email || account.email, - account.metricsOptOutAt === null + 'passwordChangeRequired', + !account.metricsOptOutAt, + { + email: account.primaryEmail?.email || account.email, + } ); const emailContent = await this.renderer.renderPasswordChangeRequired({ @@ -135,9 +136,13 @@ export class EmailService { export const EmailLinkBuilderFactory: Provider = { provide: EmailLinkBuilder, useFactory: async (config: ConfigService) => { + const contentServerConfig = config.get( + 'contentServer' + ) as AppConfig['contentServer']; const smtpConfig = config.get('smtp') as AppConfig['smtp']; const linksConfig = config.get('links') as AppConfig['links']; return new EmailLinkBuilder({ + baseUri: contentServerConfig.url, ...smtpConfig, ...linksConfig, }); diff --git a/packages/fxa-admin-server/src/config/index.ts b/packages/fxa-admin-server/src/config/index.ts index f67cce64e5f..575c73870e1 100644 --- a/packages/fxa-admin-server/src/config/index.ts +++ b/packages/fxa-admin-server/src/config/index.ts @@ -722,17 +722,25 @@ const conf = convict({ default: 'https://privacyportal.onetrust.com/webform/1350748f-7139-405c-8188-22740b3b5587/4ba08202-2ede-4934-a89e-f0b0870f95f0', }, - initiatePasswordResetUrl: { - doc: 'URL that allows a user to reset their account.', + twoFactorSupportUrl: { + doc: 'URL to unsubscribe from MoCo and MoFo emails', format: String, - env: 'ACCOUNT_RESET_URL', - default: 'http://localhost:3030/reset_password', + env: 'TWO_FACTOR_SUPPORT_URL', + default: + 'https://privacyportal.onetrust.com/webform/1350748f-7139-405c-8188-22740b3b5587/4ba08202-2ede-4934-a89e-f0b0870f95f0', }, - accountSettingsUrl: { - doc: 'URL for account settings', + mozillaSupportUrl: { + doc: 'URL to unsubscribe from MoCo and MoFo emails', format: String, - env: 'ACCOUNT_SETTINGS_URL', - default: 'http://localhost:3030/settings', + env: 'MOZILLA_SUPPORT_URL', + default: 'https://support.mozilla.org', + }, + defaultSurveyUrl: { + doc: 'The default survey link', + format: String, + env: 'DEFAULT_SURVEY_URL', + default: + 'https://survey.alchemer.com/s3/6534408/Privacy-Security-Product-Cancellation-of-Service-Q4-21', }, }, contentServer: { diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index ed609079395..27ac4cb5fd3 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -501,6 +501,11 @@ const convictConf = convict({ default: 'https://app.adjust.com/2uo1qc?campaign=fxa-conf-email&adgroup=ios&creative=button&fallback=https%3A%2F%2Fitunes.apple.com%2Fapp%2Fapple-store%2Fid989804926%3Fpt%3D373246%26ct%3Dadjust_tracker%26mt%3D8&utm_source=email', }, + mozillaSupportUrl: { + doc: 'url to general Mozilla support page', + format: String, + default: 'https://support.mozilla.org', + }, supportUrl: { doc: 'url to Mozilla account support page', format: String, @@ -523,6 +528,12 @@ const convictConf = convict({ format: String, default: 'https://www.mozilla.org/privacy/mozilla-accounts/', }, + twoFactorSupportUrl: { + doc: 'url to support page about two factor auth', + format: String, + default: + 'https://support.mozilla.org/kb/secure-mozilla-account-two-step-authentication', + }, passwordManagerInfoUrl: { doc: 'url to Firefox password manager information', format: String, @@ -549,6 +560,12 @@ const convictConf = convict({ default: 'https://privacyportal.onetrust.com/webform/1350748f-7139-405c-8188-22740b3b5587/4ba08202-2ede-4934-a89e-f0b0870f95f0', }, + defaultSurveyUrl: { + doc: 'The default survey link', + format: String, + default: + 'https://survey.alchemer.com/s3/6534408/Privacy-Security-Product-Cancellation-of-Service-Q4-21', + }, sesConfigurationSet: { doc: 'AWS SES Configuration Set for SES Event Publishing. If defined, ' + @@ -2705,6 +2722,10 @@ convictConf.set( 'smtp.accountRecoveryCodesUrl', `${baseUri}/settings/two_step_authentication/replace_codes` ); +convictConf.set( + 'smpt.twoFactorSupportUrl', + 'https://support.mozilla.org/kb/secure-mozilla-account-two-step-authentication' +); convictConf.set('smtp.verificationUrl', `${baseUri}/verify_email`); convictConf.set('smtp.pushVerificationUrl', `${baseUri}/push/confirm_login`); convictConf.set('smtp.passwordResetUrl', `${baseUri}/complete_reset_password`); diff --git a/packages/fxa-auth-server/lib/senders/fxa-mailer.ts b/packages/fxa-auth-server/lib/senders/fxa-mailer.ts index 523568bb4b0..f9c8d73350a 100644 --- a/packages/fxa-auth-server/lib/senders/fxa-mailer.ts +++ b/packages/fxa-auth-server/lib/senders/fxa-mailer.ts @@ -154,11 +154,10 @@ export class FxaMailer extends FxaEmailRenderer { const links = { privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), - link: this.linkBuilder.buildPrimaryLink( + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( template, - opts.metricsEnabled, - opts + metricsEnabled, + { email: opts.to } ), }; const headers = this.buildHeaders( @@ -184,11 +183,18 @@ export class FxaMailer extends FxaEmailRenderer { const links = { privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), - link: this.linkBuilder.buildPrimaryLink( + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { + email: opts.to, + uid: opts.uid, + } ), }; const headers = this.buildHeaders( @@ -212,13 +218,17 @@ export class FxaMailer extends FxaEmailRenderer { const { template, version } = postVerifySecondary; const { metricsEnabled } = opts; const links = { - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - link: this.linkBuilder.buildPrimaryLink( + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -244,10 +254,10 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - link: this.linkBuilder.buildPrimaryLink( + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -273,11 +283,15 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), - link: this.linkBuilder.buildPrimaryLink( + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -306,15 +320,16 @@ export class FxaMailer extends FxaEmailRenderer { const links = { privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), - mozillaSupportUrl: this.linkBuilder.buildMozillaSupportUrl( + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( template, - metricsEnabled + metricsEnabled, + { email: opts.to } ), - link: this.linkBuilder.buildPrimaryLink( + mozillaSupportUrl: this.linkBuilder.buildMozillaSupportUrl(), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -341,9 +356,17 @@ export class FxaMailer extends FxaEmailRenderer { const links = { privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), twoFactorSupportLink: this.linkBuilder.buildTwoFactorSupportLink(), - link: this.linkBuilder.buildPrimaryLink(template, metricsEnabled, opts), + link: this.linkBuilder.buildAccountSettingsLink( + template, + metricsEnabled, + { email: opts.to, uid: opts.uid } + ), }; const headers = this.buildHeaders( { template, version }, @@ -368,12 +391,16 @@ export class FxaMailer extends FxaEmailRenderer { const links = { privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), twoFactorSupportLink: this.linkBuilder.buildTwoFactorSupportLink(), - link: this.linkBuilder.buildPrimaryLink( + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -399,11 +426,15 @@ export class FxaMailer extends FxaEmailRenderer { const links = { privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), - link: this.linkBuilder.buildPrimaryLink( + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -429,8 +460,16 @@ export class FxaMailer extends FxaEmailRenderer { const links = { privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), - link: this.linkBuilder.buildPostNewRecoveryCodesLink(), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildPostNewRecoveryCodesLink( + template, + metricsEnabled, + { email: opts.to, uid: opts.uid } + ), }; const headers = this.buildHeaders( { template, version }, @@ -461,13 +500,19 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - twoFactorSettingsLink: this.linkBuilder.buildTwoFactorSettignsLink(), - link: this.linkBuilder.buildPrimaryLink(template, metricsEnabled, opts), - resetLink: this.linkBuilder.buildResetLink( + twoFactorSettingsLink: this.linkBuilder.buildTwoFactorSettignsLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildAccountSettingsLink( template, - opts.to, - metricsEnabled + metricsEnabled, + { email: opts.to, uid: opts.uid } ), + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), }; const headers = this.buildHeaders( { template, version }, @@ -490,13 +535,16 @@ export class FxaMailer extends FxaEmailRenderer { const { template, version } = lowRecoveryCodes; const { metricsEnabled } = opts; const links = { - ...this.linkBuilder.buildCommonLinks(template, metricsEnabled), - link: this.linkBuilder.buildPrimaryLink(template, metricsEnabled, opts), - resetLink: this.linkBuilder.buildResetLink( + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + link: this.linkBuilder.buildAccountSettingsLink( template, - opts.to, - metricsEnabled + metricsEnabled, + { email: opts.to, uid: opts.uid } ), + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), }; const headers = this.buildHeaders( { template, version }, @@ -525,16 +573,15 @@ export class FxaMailer extends FxaEmailRenderer { const { template, version } = postSigninRecoveryCode; const { metricsEnabled } = opts; const links = { - ...this.linkBuilder.buildCommonLinks(template, opts.metricsEnabled), - resetLink: this.linkBuilder.buildResetLink( - template, - opts.to, - metricsEnabled - ), - link: this.linkBuilder.buildPrimaryLink( + supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), + privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -561,15 +608,13 @@ export class FxaMailer extends FxaEmailRenderer { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), twoFactorSupportLink: this.linkBuilder.buildTwoFactorSupportLink(), - resetLink: this.linkBuilder.buildResetLink( - template, - opts.to, - metricsEnabled - ), - link: this.linkBuilder.buildPrimaryLink( + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -595,11 +640,9 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - resetLink: this.linkBuilder.buildResetLink( - template, - opts.to, - metricsEnabled - ), + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), }; const headers = this.buildHeaders( { template, version }, @@ -624,15 +667,13 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - resetLink: this.linkBuilder.buildResetLink( - template, - opts.to, - metricsEnabled - ), - link: this.linkBuilder.buildPrimaryLink( + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -658,13 +699,19 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - resetLink: this.linkBuilder.buildResetLink( + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), + twoFactorSettingsLink: this.linkBuilder.buildTwoFactorSettignsLink( + template, + metricsEnabled, + { email: opts.to } + ), + link: this.linkBuilder.buildAccountSettingsLink( template, - opts.to, - metricsEnabled + metricsEnabled, + { email: opts.to, uid: opts.uid } ), - twoFactorSettingsLink: this.linkBuilder.buildTwoFactorSettignsLink(), - link: this.linkBuilder.buildPrimaryLink(template, metricsEnabled, opts), }; const headers = this.buildHeaders( { template, version }, @@ -689,15 +736,13 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - resetLink: this.linkBuilder.buildResetLink( - template, - opts.to, - metricsEnabled - ), - link: this.linkBuilder.buildPrimaryLink( + resetLink: this.linkBuilder.buildResetLink(template, metricsEnabled, { + email: opts.to, + }), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -723,13 +768,23 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), revokeAccountRecoveryLink: - this.linkBuilder.buildRevokeAccountRecoveryLink(), - link: this.linkBuilder.buildPrimaryLink( + this.linkBuilder.buildRevokeAccountRecoveryLink( + template, + metricsEnabled + ), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { + email: opts.to, + uid: opts.uid, + } ), }; const headers = this.buildHeaders( @@ -755,13 +810,20 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), revokeAccountRecoveryLink: - this.linkBuilder.buildRevokeAccountRecoveryLink(), - link: this.linkBuilder.buildPrimaryLink( + this.linkBuilder.buildRevokeAccountRecoveryLink( + template, + metricsEnabled + ), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -787,13 +849,20 @@ export class FxaMailer extends FxaEmailRenderer { const links = { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), - passwordChangeLink: this.linkBuilder.buildPasswordChangeLink(), + passwordChangeLink: this.linkBuilder.buildPasswordChangeLink( + template, + metricsEnabled, + { email: opts.to } + ), revokeAccountRecoveryLink: - this.linkBuilder.buildRevokeAccountRecoveryLink(), - link: this.linkBuilder.buildPrimaryLink( + this.linkBuilder.buildRevokeAccountRecoveryLink( + template, + metricsEnabled + ), + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -820,14 +889,14 @@ export class FxaMailer extends FxaEmailRenderer { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), passwordChangeLink: this.linkBuilder.buildPasswordChangeRequiredLink( - this.linkBuilder.urls.initiatePasswordReset, // TODO: Check that this is the right base url - opts.to, - opts.metricsEnabled + template, + metricsEnabled, + { email: opts.to } ), - link: this.linkBuilder.buildPrimaryLink( + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders( @@ -854,14 +923,14 @@ export class FxaMailer extends FxaEmailRenderer { supportUrl: this.linkBuilder.buildSupportLink(template, metricsEnabled), privacyUrl: this.linkBuilder.buildPrivacyLink(template, metricsEnabled), passwordChangeLink: this.linkBuilder.buildPasswordChangeRequiredLink( - this.linkBuilder.urls.initiatePasswordReset, // TODO: Double check this is the right url - opts.to, - opts.metricsEnabled + template, + metricsEnabled, + { email: opts.to } ), - link: this.linkBuilder.buildPrimaryLink( + link: this.linkBuilder.buildAccountSettingsLink( template, opts.metricsEnabled, - opts + { email: opts.to, uid: opts.uid } ), }; const headers = this.buildHeaders(