From 33ae2643576aa9d6b8412f47f0f790e1aca6980e Mon Sep 17 00:00:00 2001 From: Nicholas Shirley Date: Tue, 23 Dec 2025 08:21:13 -0700 Subject: [PATCH] feat(auth): Use libs/email sender in fxa-auth-server Because: - The ability to render and send emails was moved out of auth-server This Commit: - Updates the first emails (password reset) to use the new email-sender - Updates a few templates that needed changed - Adds a new email-helper for general functions around localized date strings, and splitting a list of emails into 'to' and 'cc' - Updates tests --- libs/accounts/email-renderer/src/index.ts | 2 + .../src/partials/userInfo/index.ts | 1 + .../src/renderer/email-helpers.spec.ts | 140 +++++++++++++ .../src/renderer/email-helpers.ts | 101 ++++++++++ .../src/renderer/email-link-builder.spec.ts | 190 ++++++++++++++++++ .../src/renderer/email-link-builder.ts | 181 ++++++++++++++++- .../src/renderer/email-renderer.ts | 18 +- .../src/renderer/fxa-email-renderer.spec.ts | 1 + .../email-renderer/src/renderer/index.ts | 1 + .../email-renderer/src/templates/index.ts | 70 ++++++- .../src/templates/recovery/index.ts | 5 - .../email-sender/src/email-sender.spec.ts | 4 +- .../accounts/email-sender/src/email-sender.ts | 22 +- .../src/backend/email.service.ts | 10 +- packages/fxa-admin-server/src/config/index.ts | 6 + packages/fxa-auth-server/bin/key_server.js | 27 ++- packages/fxa-auth-server/config/index.ts | 32 +++ .../fxa-auth-server/lib/routes/password.ts | 91 +++++---- .../fxa-auth-server/lib/senders/fxa-mailer.ts | 154 ++++++++++++++ packages/fxa-auth-server/lib/types.ts | 2 +- .../test/local/routes/password.js | 57 ++++-- packages/fxa-shared/lib/user-agent.ts | 33 +++ 22 files changed, 1068 insertions(+), 80 deletions(-) create mode 100644 libs/accounts/email-renderer/src/renderer/email-helpers.spec.ts create mode 100644 libs/accounts/email-renderer/src/renderer/email-helpers.ts create mode 100644 libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts create mode 100644 packages/fxa-auth-server/lib/senders/fxa-mailer.ts diff --git a/libs/accounts/email-renderer/src/index.ts b/libs/accounts/email-renderer/src/index.ts index add301d2d30..fa6a3fb0a0b 100644 --- a/libs/accounts/email-renderer/src/index.ts +++ b/libs/accounts/email-renderer/src/index.ts @@ -6,3 +6,5 @@ export * from './renderer'; export * from './renderer/bindings-node'; export * from './bindings'; export * from './templates'; +export * from './layouts/fxa'; +export * from './renderer/email-helpers'; diff --git a/libs/accounts/email-renderer/src/partials/userInfo/index.ts b/libs/accounts/email-renderer/src/partials/userInfo/index.ts index aed2d8e3fb5..671a836f09e 100644 --- a/libs/accounts/email-renderer/src/partials/userInfo/index.ts +++ b/libs/accounts/email-renderer/src/partials/userInfo/index.ts @@ -11,4 +11,5 @@ export type TemplateData = UserDeviceTemplateData & primaryEmail?: string; date?: string; time?: string; + //acceptLanguage: string; }; diff --git a/libs/accounts/email-renderer/src/renderer/email-helpers.spec.ts b/libs/accounts/email-renderer/src/renderer/email-helpers.spec.ts new file mode 100644 index 00000000000..c2004a5a681 --- /dev/null +++ b/libs/accounts/email-renderer/src/renderer/email-helpers.spec.ts @@ -0,0 +1,140 @@ +/* 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 { + constructLocalDateString, + constructLocalTimeAndDateStrings, +} from './email-helpers'; + +describe('EmailHelpers', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructLocalTimeAndDateStrings', () => { + beforeEach(() => { + jest + .spyOn(Date, 'now') + .mockReturnValue(new Date('2024-01-15T20:30:45Z').getTime()); + }); + + it('should construct time and date strings with timezone', () => { + const result = constructLocalTimeAndDateStrings( + 'America/Los_Angeles', + 'en-US' + ); + + expect(result.time).toEqual('12:30:45 PM (PST)'); + expect(result.date).toEqual('Monday, Jan 15, 2024'); + }); + + it('should use default timezone when not provided', () => { + const result = constructLocalTimeAndDateStrings(undefined, 'en-US'); + + expect(result.time).toEqual('8:30:45 PM (UTC)'); + expect(result.date).toEqual('Monday, Jan 15, 2024'); + expect(result.acceptLanguage).toEqual('en-US'); + expect(result.timeZone).toEqual('Etc/UTC'); + }); + + it('should use default locale when not provided', () => { + const result = constructLocalTimeAndDateStrings('America/New_York'); + + expect(result.time).toEqual('3:30:45 PM (EST)'); + expect(result.date).toEqual('Monday, Jan 15, 2024'); + expect(result.acceptLanguage).toEqual('en'); + expect(result.timeZone).toEqual('America/New_York'); + }); + + it('should handle different locales', () => { + const result = constructLocalTimeAndDateStrings('Europe/London', 'fr-FR'); + + expect(result.time).toEqual('20:30:45 (GMT)'); + expect(result.date).toEqual('lundi, 15 janv. 2024'); + }); + + it('should format with no parameters', () => { + const result = constructLocalTimeAndDateStrings(); + + expect(result.time).toEqual('8:30:45 PM (UTC)'); + expect(result.date).toEqual('Monday, Jan 15, 2024'); + }); + }); + + describe('constructLocalDateString', () => { + it('should construct localized date string with timezone', () => { + const date = new Date('2024-01-15T12:00:00Z'); + + const result = constructLocalDateString( + 'America/Los_Angeles', + 'en-US', + date + ); + + expect(result).toEqual('01/15/2024'); + }); + + it('should accept custom format string', () => { + const date = new Date('2024-01-15T12:00:00Z'); + + const result = constructLocalDateString( + 'America/Chicago', + 'en-US', + date, + 'YYYY-MM-DD' + ); + + expect(result).toEqual('2024-01-15'); + }); + + it('should use current date when not provided', () => { + jest + .spyOn(Date, 'now') + .mockReturnValue(new Date('2024-01-15T12:00:00Z').getTime()); + + const result = constructLocalDateString('America/Denver', 'en-US'); + + expect(result).toEqual('01/15/2024'); + }); + + it('should handle different locales', () => { + const date = new Date('2024-01-15T12:00:00Z'); + + const result = constructLocalDateString('Europe/Paris', 'de-DE', date); + + expect(result).toEqual('15.01.2024'); + }); + + it('should accept timestamp as date parameter', () => { + jest + .spyOn(Date, 'now') + .mockReturnValue(new Date('2025-12-31T23:59:59Z').getTime()); + + const timestamp = Date.now(); + + const result = constructLocalDateString( + 'Asia/Tokyo', + 'ja-JP', + timestamp // epoch timestamp instead of Date object + ); + + expect(result).toEqual('2026/01/01'); + }); + + it('should use default timezone when not provided', () => { + const date = new Date('2024-01-15T02:00:00Z'); + const resultDefault = constructLocalDateString(undefined, 'en-US', date); + + const resultEst = constructLocalDateString( + 'America/New_York', + 'en-US', + date + ); + + expect(resultDefault).not.toBe(resultEst); + expect(resultDefault).toEqual('01/15/2024'); + expect(resultEst).toEqual('01/14/2024'); + }); + }); +}); diff --git a/libs/accounts/email-renderer/src/renderer/email-helpers.ts b/libs/accounts/email-renderer/src/renderer/email-helpers.ts new file mode 100644 index 00000000000..f7b314396d6 --- /dev/null +++ b/libs/accounts/email-renderer/src/renderer/email-helpers.ts @@ -0,0 +1,101 @@ +/* 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 moment from 'moment-timezone'; +import { determineLocale } from '@fxa/shared/l10n'; + +const DEFAULT_LOCALE = 'en'; +const DEFAULT_TIMEZONE = 'Etc/UTC'; + +/** + * Takes a list of emails, returning the primary email as "to" and + * verified, non-primary emails as "cc". + * @param emails + * @returns + */ +export const splitEmails = ( + emails: { email: string; isPrimary?: boolean; isVerified?: boolean }[] +): { to: string; cc: string[] } => { + return emails.reduce( + (result: { to: string; cc: string[] }, item) => { + const { email } = item; + + if (item.isPrimary) { + result.to = email; + } else if (item.isVerified) { + result.cc.push(email); + } + + return result; + }, + { to: '', cc: [] } + ); +}; + +/** + * Construct a localized time string with timezone. + * Returns an object containing the localized time and date strings, + * as well as the acceptLanguage and timeZone used to generate them. + * + * Example output: ['9:41:00 AM (PDT)', 'Monday, Jan 1, 2024'] + * + * @param timeZone - IANA timezone (e.g., 'America/Los_Angeles') + * @param acceptLanguage - Accept-Language header value + */ +export const constructLocalTimeAndDateStrings = ( + timeZone?: string, + acceptLanguage?: string +): { + acceptLanguage: string; + date: string; + time: string; + timeZone: string; +} => { + moment.tz.setDefault(DEFAULT_TIMEZONE); + + const locale = determineLocale(acceptLanguage) || DEFAULT_LOCALE; + moment.locale(locale); + + let timeMoment = moment(); + if (timeZone) { + timeMoment = timeMoment.tz(timeZone); + } + + const time = timeMoment.format('LTS (z)'); + const date = timeMoment.format('dddd, ll'); + + return { + acceptLanguage: locale, + date, + time, + timeZone: timeZone || DEFAULT_TIMEZONE, + }; +}; + +/** + * Construct a localized date string. + * + * @param timeZone - IANA timezone (e.g., 'America/Los_Angeles') + * @param acceptLanguage - Accept-Language header value + * @param date - Date to format (defaults to now) + * @param formatString - Moment.js format string (defaults to 'L' for localized date) + */ +export const constructLocalDateString = ( + timeZone?: string, + acceptLanguage?: string, + date?: Date | number, + formatString = 'L' +): string => { + moment.tz.setDefault(DEFAULT_TIMEZONE); + + const locale = determineLocale(acceptLanguage) || DEFAULT_LOCALE; + moment.locale(locale); + + let time = moment(date); + if (timeZone) { + time = time.tz(timeZone); + } + + return time.format(formatString); +}; 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 new file mode 100644 index 00000000000..36feed2c370 --- /dev/null +++ b/libs/accounts/email-renderer/src/renderer/email-link-builder.spec.ts @@ -0,0 +1,190 @@ +/* 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 { EmailLinkBuilder } from './email-link-builder'; + +describe('EmailLinkBuilder', () => { + const mockConfig = { + metricsEnabled: true, + initiatePasswordResetUrl: 'http://localhost:3030/reset_password', + privacyUrl: 'http://localhost:3030/privacy', + supportUrl: 'http://localhost:3030/support', + }; + + let linkBuilder: EmailLinkBuilder; + + beforeEach(() => { + linkBuilder = new EmailLinkBuilder(mockConfig); + }); + + describe('urls getter', () => { + it('should return configured URLs', () => { + const urls = linkBuilder.urls; + + expect(urls.initiatePasswordReset).toBe( + 'http://localhost:3030/reset_password' + ); + expect(urls.privacy).toBe('http://localhost:3030/privacy'); + expect(urls.support).toBe('http://localhost:3030/support'); + }); + }); + + describe('getCampaign', () => { + it('should return campaign with prefix for valid template', () => { + const templateName = 'recovery'; + + const campaign = linkBuilder.getCampaign(templateName); + + expect(campaign).toBe('fx-forgot-password'); + }); + + it('should return empty string for unknown template', () => { + const templateName = 'unknownTemplate'; + + const campaign = linkBuilder.getCampaign(templateName); + + expect(campaign).toBe(''); + }); + }); + + describe('getContent', () => { + it('should return content for valid template', () => { + const templateName = 'recovery'; + + const content = linkBuilder.getContent(templateName); + + expect(content).toBe('reset-password'); + }); + + it('should return empty string for unknown template', () => { + const templateName = 'unknownTemplate'; + + const content = linkBuilder.getContent(templateName); + + expect(content).toBe(''); + }); + }); + + describe('addUTMParams', () => { + it('should add UTM parameters when metrics enabled', () => { + const link = new URL('http://localhost:3030/some-page'); + const templateName = 'recovery'; + + linkBuilder.addUTMParams(link, templateName); + + expect(link.searchParams.get('utm_medium')).toBe('email'); + expect(link.searchParams.get('utm_campaign')).toBe('fx-forgot-password'); + expect(link.searchParams.get('utm_content')).toBe('fx-reset-password'); + }); + + it('should use custom content when provided', () => { + const link = new URL('http://localhost:3030/some-page'); + const templateName = 'recovery'; + const customContent = 'custom-content'; + + linkBuilder.addUTMParams(link, templateName, customContent); + + expect(link.searchParams.get('utm_content')).toBe('fx-custom-content'); + }); + + it('should not add UTM parameters when metrics disabled', () => { + const disabledLinkBuilder = new EmailLinkBuilder({ + ...mockConfig, + metricsEnabled: false, + }); + const link = new URL('http://localhost:3030/some-page'); + const templateName = 'recovery'; + + disabledLinkBuilder.addUTMParams(link, templateName); + + expect(link.searchParams.get('utm_medium')).toBeNull(); + expect(link.searchParams.get('utm_campaign')).toBeNull(); + expect(link.searchParams.get('utm_content')).toBeNull(); + }); + + it('should not override existing utm_campaign', () => { + const link = new URL( + 'http://localhost:3030/some-page?utm_campaign=existing' + ); + const templateName = 'recovery'; + + linkBuilder.addUTMParams(link, templateName); + + expect(link.searchParams.get('utm_campaign')).toBe('existing'); + }); + }); + + describe('buildCommonLinks', () => { + it('should build privacy and support links with UTM params', () => { + const templateName = 'recovery'; + + const links = linkBuilder.buildCommonLinks(templateName); + + 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'); + + 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'); + }); + }); + + describe('buildLinkWithQueryParamsAndUTM', () => { + it('should add query params and UTM params to link', () => { + const link = new URL('http://localhost:3030/some-page'); + const templateName = 'recovery'; + const queryParams = { + uid: '12345', + token: 'abc123', + email: 'test@example.com', + }; + + linkBuilder.buildLinkWithQueryParamsAndUTM( + link, + templateName, + queryParams + ); + + expect(link.searchParams.get('uid')).toBe('12345'); + expect(link.searchParams.get('token')).toBe('abc123'); + expect(link.searchParams.get('email')).toBe('test@example.com'); + expect(link.searchParams.get('utm_medium')).toBe('email'); + expect(link.searchParams.get('utm_campaign')).toBe('fx-forgot-password'); + }); + + it('should handle empty query params', () => { + const link = new URL('http://localhost:3030/some-page'); + const templateName = 'recovery'; + const queryParams = {}; + + linkBuilder.buildLinkWithQueryParamsAndUTM( + link, + templateName, + queryParams + ); + + expect(link.searchParams.get('utm_medium')).toBe('email'); + expect(link.searchParams.get('utm_campaign')).toBe('fx-forgot-password'); + }); + }); + + describe('buildPasswordChangeRequiredLink', () => { + it('should build password change required link with params', () => { + const opts = { + url: 'http://localhost:3030/reset_password', + email: 'test@example.com', + }; + + const link = linkBuilder.buildPasswordChangeRequiredLink(opts); + + expect(link).toContain('http://localhost:3030/reset_password'); + expect(link).toContain('email=test%40example.com'); + expect(link).toContain('utm_medium=email'); + expect(link).toContain('utm_campaign=fx-password-reset-required'); + }); + }); +}); 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 39f63695af5..4fff90ff223 100644 --- a/libs/accounts/email-renderer/src/renderer/email-link-builder.ts +++ b/libs/accounts/email-renderer/src/renderer/email-link-builder.ts @@ -2,17 +2,186 @@ * 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/. */ +const UTM_PREFIX = 'fx-'; + +// Campaign names for different email templates +const TEMPLATE_NAME_TO_CAMPAIGN_MAP: Record = { + recovery: 'forgot-password', + passwordChanged: 'password-changed-success', + passwordForgotOtp: 'password-forgot-otp', + passwordReset: 'password-reset-success', + passwordResetAccountRecovery: 'password-reset-account-recovery-success', + passwordResetRequired: 'password-reset-required', + newDeviceLogin: 'new-device-signin', + postAddAccountRecovery: 'account-recovery-generated', + postAddTwoStepAuthentication: '2fa-enabled', + postChangePrimary: 'account-change-email', + postConsumeRecoveryCode: 'account-consume-recovery-code', + postNewRecoveryCodes: 'account-replace-recovery-codes', + postRemoveAccountRecovery: 'account-recovery-removed', + postRemoveSecondary: 'account-email-removed', + postRemoveTwoStepAuthentication: '2fa-disabled', + postVerify: 'account-verified', + postVerifySecondary: 'account-email-verified', + postAddLinkedAccount: 'account-linked', + postAddAccountRecoveryConfirm: 'confirm-account-recovery', + verifyEmail: 'welcome', + verifyLoginCode: 'new-device-signin', + verifyLogin: 'new-device-signin', + verifyPrimary: 'welcome-primary', + verifySecondaryCode: 'verify-secondary-email', + verifyShortCode: 'welcome', + unblockCode: 'new-device-signin', + verificationReminderFirst: 'first-verification-reminder', + verificationReminderSecond: 'second-verification-reminder', + verificationReminderFinal: 'final-verification-reminder', + cadReminderFirst: 'first-cad-reminder', + cadReminderSecond: 'second-cad-reminder', + lowRecoveryCodes: 'low-recovery-codes', + postChangeAccountRecovery: 'post-change-account-recovery', +}; + +// Content names for different email templates +const TEMPLATE_NAME_TO_CONTENT_MAP: Record = { + recovery: 'reset-password', + passwordChanged: 'password-change', + passwordForgotOtp: 'password-reset', + passwordReset: 'password-reset', + passwordResetAccountRecovery: 'password-reset-account-recovery', + passwordResetRequired: 'password-reset-required', + newDeviceLogin: 'new-device-signin', + postAddAccountRecovery: 'account-recovery-generated', + postAddTwoStepAuthentication: '2fa-enabled', + postChangePrimary: 'account-change-email', + postConsumeRecoveryCode: 'account-consume-recovery-code', + postNewRecoveryCodes: 'account-replace-recovery-codes', + postRemoveAccountRecovery: 'account-recovery-removed', + postRemoveSecondary: 'account-email-removed', + postRemoveTwoStepAuthentication: '2fa-disabled', + postVerify: 'account-verified', + postVerifySecondary: 'account-email-verified', + postAddLinkedAccount: 'account-linked', + postAddAccountRecoveryConfirm: 'confirm-account-recovery', + verifyEmail: 'welcome', + verifyLoginCode: 'new-device-signin', + verifyLogin: 'new-device-signin', + verifyPrimary: 'welcome-primary', + verifySecondaryCode: 'verify-secondary-email', + verifyShortCode: 'welcome', + unblockCode: 'new-device-signin', + verificationReminderFirst: 'first-verification-reminder', + verificationReminderSecond: 'second-verification-reminder', + verificationReminderFinal: 'final-verification-reminder', + cadReminderFirst: 'first-cad-reminder', + cadReminderSecond: 'second-cad-reminder', + lowRecoveryCodes: 'low-recovery-codes', + postChangeAccountRecovery: 'post-change-account-recovery', +}; + +export interface EmailLinkBuilderConfig { + metricsEnabled: boolean; + initiatePasswordResetUrl: string; + privacyUrl: string; + supportUrl: string; +} + export class EmailLinkBuilder { - constructor() {} + constructor(private readonly config: EmailLinkBuilderConfig) {} + + /** + * Common base URLs used in emails. Most often paired with UTM parameters + * to attach tracking info. + */ + get urls() { + return { + initiatePasswordReset: this.config.initiatePasswordResetUrl, + privacy: this.config.privacyUrl, + support: this.config.supportUrl, + }; + } + + /** + * Adds UTM parameters to the provided link if metrics are enabled. + * @param link - URL object to add parameters to + * @param templateName - Email template name (used to lookup campaign) + * @param content - Optional content override (defaults to template's content map value) + */ + addUTMParams(link: URL, templateName: string, content?: string): void { + if (!this.config.metricsEnabled) { + return; + } + + link.searchParams.set('utm_medium', 'email'); + + const campaign = this.getCampaign(templateName); + if (campaign && !link.searchParams.has('utm_campaign')) { + link.searchParams.set('utm_campaign', campaign); + } + + const contentValue = content || this.getContent(templateName); + if (contentValue) { + link.searchParams.set('utm_content', UTM_PREFIX + contentValue); + } + } + + /** + * Build common links with UTM parameters (privacy, support) + * @param templateName + * @returns Object containing privacyUrl and supportUrl as strings + */ + buildCommonLinks(templateName: string) { + const privacyUrl = new URL(this.urls.privacy); + const supportUrl = new URL(this.urls.support); + + this.addUTMParams(privacyUrl, templateName, 'privacy'); + this.addUTMParams(supportUrl, templateName, 'support'); + + return { + privacyUrl: privacyUrl.toString(), + supportUrl: supportUrl.toString(), + }; + } + + /** + * Get the UTM campaign name for a template + */ + getCampaign(templateName: string): string { + const campaign = TEMPLATE_NAME_TO_CAMPAIGN_MAP[templateName]; + // should probably have some logging/statsd if campaign is undefined + return campaign ? UTM_PREFIX + campaign : ''; + } + + /** + * Get the UTM content name for a template + */ + getContent(templateName: string): string { + return TEMPLATE_NAME_TO_CONTENT_MAP[templateName] || ''; + } + + /** + * Adds query parameters to the provided link; includes UTM parameters if metrics are enabled. + * @param link + * @param templateName + * @param opts + */ + buildLinkWithQueryParamsAndUTM( + link: URL | string, + templateName: string, + opts: Record + ) { + if (typeof link === 'string') { + link = new URL(link); + } + for (const [key, value] of Object.entries(opts)) { + link.searchParams.set(key, value); + } + this.addUTMParams(link, templateName); + } buildPasswordChangeRequiredLink(opts: { url: string; email: string }) { const link = new URL(opts.url); - link.searchParams.set('utm_campaign', 'account-locked'); - link.searchParams.set('utm_content', 'fx-account-locked'); - link.searchParams.set('utm_medium', 'email'); + this.addUTMParams(link, 'passwordResetRequired'); link.searchParams.set('email', opts.email); return link.toString(); } - - // TOOD: Port remaining link building logic from auth-server! } diff --git a/libs/accounts/email-renderer/src/renderer/email-renderer.ts b/libs/accounts/email-renderer/src/renderer/email-renderer.ts index d760ef3c7ee..b98281c08ea 100644 --- a/libs/accounts/email-renderer/src/renderer/email-renderer.ts +++ b/libs/accounts/email-renderer/src/renderer/email-renderer.ts @@ -20,8 +20,18 @@ const RTL_LOCALES = [ 'pa', ]; +export type RenderedTemplate = { + language: string; + html: string; + text: string; + subject: string; + preview: string; + template: string; + version: number; +}; + /** - * Base calss for rendering an MJML email template. + * Base class for rendering an MJML email template. * Ported from fxa-auth-server lib/senders/emails **/ export class EmailRenderer extends Localizer { @@ -62,7 +72,9 @@ export class EmailRenderer extends Localizer { * @returns text Plaintext rendered through EJS and localized * @returns subject Localized subject, for mailer use */ - async renderEmail(templateContext: TemplateContext) { + async renderEmail( + templateContext: TemplateContext + ): Promise { const { acceptLanguage, template, @@ -72,7 +84,7 @@ export class EmailRenderer extends Localizer { includes, } = templateContext; const { l10n, selectedLocale } = await super.setupDomLocalizer( - acceptLanguage || '' + acceptLanguage || 'en-US' ); const context = { 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 7ab4c144ce3..c4810973ba1 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 @@ -506,6 +506,7 @@ describe('FxA Email Renderer', () => { resetLink: mockLinkReset, twoFactorSettingsLink: mockLinkSupport, supportUrl: mockLinkSupport, + // }, defaultLayoutTemplateValues ); diff --git a/libs/accounts/email-renderer/src/renderer/index.ts b/libs/accounts/email-renderer/src/renderer/index.ts index 2993382fa24..7f0a945069a 100644 --- a/libs/accounts/email-renderer/src/renderer/index.ts +++ b/libs/accounts/email-renderer/src/renderer/index.ts @@ -6,6 +6,7 @@ export * from './email-renderer'; export * from './fxa-email-renderer'; export * from './subplat-email-renderer'; export * from './email-link-builder'; +export * from './email-helpers'; // Important! do not export ./bindings-node. // Doing so will break storybook, since this file cannot be processed diff --git a/libs/accounts/email-renderer/src/templates/index.ts b/libs/accounts/email-renderer/src/templates/index.ts index a9559468a2b..536ba619c3a 100644 --- a/libs/accounts/email-renderer/src/templates/index.ts +++ b/libs/accounts/email-renderer/src/templates/index.ts @@ -1 +1,69 @@ -export * from './cadReminderFirst'; +/* 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/. */ + +export * as verify from './verify'; +export * as verifyLogin from './verifyLogin'; +export * as verifyShortCode from './verifyShortCode'; +export * as verifyPrimary from './verifyPrimary'; +export * as verifySecondaryCode from './verifySecondaryCode'; +export * as verifyAccountChange from './verifyAccountChange'; +export * as verifyLoginCode from './verifyLoginCode'; +export * as recovery from './recovery'; +export * as passwordReset from './passwordReset'; +export * as passwordChanged from './passwordChanged'; +export * as passwordChangeRequired from './passwordChangeRequired'; +export * as passwordForgotOtp from './passwordForgotOtp'; +export * as passwordResetAccountRecovery from './passwordResetAccountRecovery'; +export * as passwordResetRecoveryPhone from './passwordResetRecoveryPhone'; +export * as passwordResetWithRecoveryKeyPrompt from './passwordResetWithRecoveryKeyPrompt'; +export * as newDeviceLogin from './newDeviceLogin'; +export * as lowRecoveryCodes from './lowRecoveryCodes'; +export * as postVerify from './postVerify'; +export * as postVerifySecondary from './postVerifySecondary'; +export * as postChangePrimary from './postChangePrimary'; +export * as postRemoveSecondary from './postRemoveSecondary'; +export * as postAddLinkedAccount from './postAddLinkedAccount'; +export * as postAddTwoStepAuthentication from './postAddTwoStepAuthentication'; +export * as postChangeTwoStepAuthentication from './postChangeTwoStepAuthentication'; +export * as postRemoveTwoStepAuthentication from './postRemoveTwoStepAuthentication'; +export * as postConsumeRecoveryCode from './postConsumeRecoveryCode'; +export * as postNewRecoveryCodes from './postNewRecoveryCodes'; +export * as postAddAccountRecovery from './postAddAccountRecovery'; +export * as postChangeAccountRecovery from './postChangeAccountRecovery'; +export * as postRemoveAccountRecovery from './postRemoveAccountRecovery'; +export * as postAddRecoveryPhone from './postAddRecoveryPhone'; +export * as postChangeRecoveryPhone from './postChangeRecoveryPhone'; +export * as postRemoveRecoveryPhone from './postRemoveRecoveryPhone'; +export * as postSigninRecoveryPhone from './postSigninRecoveryPhone'; +export * as postSigninRecoveryCode from './postSigninRecoveryCode'; +export * as unblockCode from './unblockCode'; +export * as verificationReminderFirst from './verificationReminderFirst'; +export * as verificationReminderSecond from './verificationReminderSecond'; +export * as verificationReminderFinal from './verificationReminderFinal'; +export * as cadReminderFirst from './cadReminderFirst'; +export * as cadReminderSecond from './cadReminderSecond'; +export * as downloadSubscription from './downloadSubscription'; +export * as fraudulentAccountDeletion from './fraudulentAccountDeletion'; +export * as inactiveAccountFirstWarning from './inactiveAccountFirstWarning'; +export * as inactiveAccountSecondWarning from './inactiveAccountSecondWarning'; +export * as inactiveAccountFinalWarning from './inactiveAccountFinalWarning'; +export * as subscriptionAccountFinishSetup from './subscriptionAccountFinishSetup'; +export * as subscriptionAccountReminderFirst from './subscriptionAccountReminderFirst'; +export * as subscriptionAccountReminderSecond from './subscriptionAccountReminderSecond'; +export * as subscriptionReactivation from './subscriptionReactivation'; +export * as subscriptionRenewalReminder from './subscriptionRenewalReminder'; +export * as subscriptionReplaced from './subscriptionReplaced'; +export * as subscriptionUpgrade from './subscriptionUpgrade'; +export * as subscriptionDowngrade from './subscriptionDowngrade'; +export * as subscriptionPaymentExpired from './subscriptionPaymentExpired'; +export * as subscriptionsPaymentExpired from './subscriptionsPaymentExpired'; +export * as subscriptionPaymentProviderCancelled from './subscriptionPaymentProviderCancelled'; +export * as subscriptionsPaymentProviderCancelled from './subscriptionsPaymentProviderCancelled'; +export * as subscriptionPaymentFailed from './subscriptionPaymentFailed'; +export * as subscriptionAccountDeletion from './subscriptionAccountDeletion'; +export * as subscriptionCancellation from './subscriptionCancellation'; +export * as subscriptionFailedPaymentsCancellation from './subscriptionFailedPaymentsCancellation'; +export * as subscriptionSubsequentInvoice from './subscriptionSubsequentInvoice'; +export * as subscriptionFirstInvoice from './subscriptionFirstInvoice'; +export * as adminResetAccounts from './adminResetAccounts'; diff --git a/libs/accounts/email-renderer/src/templates/recovery/index.ts b/libs/accounts/email-renderer/src/templates/recovery/index.ts index f950f3a346f..4efc7884ca3 100644 --- a/libs/accounts/email-renderer/src/templates/recovery/index.ts +++ b/libs/accounts/email-renderer/src/templates/recovery/index.ts @@ -9,11 +9,6 @@ export type TemplateData = AutomatedEmailNoActionTemplateData & UserInfoTemplateData & { link: string; time: string; - device: { - uaBrowser: string; - uaOSVersion: string; - uaOS: string; - }; location: { stateCode: string; country: string; diff --git a/libs/accounts/email-sender/src/email-sender.spec.ts b/libs/accounts/email-sender/src/email-sender.spec.ts index dc4377eab43..946a9b6ab0d 100644 --- a/libs/accounts/email-sender/src/email-sender.spec.ts +++ b/libs/accounts/email-sender/src/email-sender.spec.ts @@ -394,7 +394,7 @@ describe('EmailSender', () => { }); describe('buildHeaders', () => { it('builds basic headers without SES configuration', async () => { - const headers = await emailSender.buildHeaders({ + const headers = emailSender.buildHeaders({ template: { name: 'test-template', version: 1, @@ -417,7 +417,7 @@ describe('EmailSender', () => { }); it('builds headers with optional context fields', async () => { - const headers = await emailSender.buildHeaders({ + const headers = emailSender.buildHeaders({ template: { name: 'test-template', version: 2, diff --git a/libs/accounts/email-sender/src/email-sender.ts b/libs/accounts/email-sender/src/email-sender.ts index 1191623b6a9..fe656062792 100644 --- a/libs/accounts/email-sender/src/email-sender.ts +++ b/libs/accounts/email-sender/src/email-sender.ts @@ -152,28 +152,28 @@ export class EmailSender { /** * Builds standard email headers */ - async buildHeaders({ - template, + buildHeaders({ context, headers, + template, }: { - template: { - name: string; - version: number; - metricsName?: string; - }; context: { - serverName: string; language: string; + serverName: string; + cmsRpClientId?: string; deviceId?: string; - flowId?: string; + entrypoint?: string; flowBeginTime?: number; + flowId?: string; service?: string; uid?: string; - entrypoint?: string; - cmsRpClientId?: string; }; headers: Record; + template: { + name: string; + version: number; + metricsName?: string; + }; }) { const optionalHeader = (key: string, value?: string | number) => { if (value) { diff --git a/packages/fxa-admin-server/src/backend/email.service.ts b/packages/fxa-admin-server/src/backend/email.service.ts index 5819d3fcc72..ac2f1948b87 100644 --- a/packages/fxa-admin-server/src/backend/email.service.ts +++ b/packages/fxa-admin-server/src/backend/email.service.ts @@ -133,9 +133,15 @@ export class EmailService { export const EmailLinkBuilderFactory: Provider = { provide: EmailLinkBuilder, - useFactory: async () => { - return new EmailLinkBuilder(); + useFactory: async (config: ConfigService) => { + const smtpConfig = config.get('smtp') as AppConfig['smtp']; + const linksConfig = config.get('links') as AppConfig['links']; + return new EmailLinkBuilder({ + ...smtpConfig, + ...linksConfig, + }); }, + inject: [ConfigService], }; export const FxaEmailRendererFactory: Provider = { diff --git a/packages/fxa-admin-server/src/config/index.ts b/packages/fxa-admin-server/src/config/index.ts index 6e4e14a3afa..d2cefedd540 100644 --- a/packages/fxa-admin-server/src/config/index.ts +++ b/packages/fxa-admin-server/src/config/index.ts @@ -880,6 +880,12 @@ const conf = convict({ env: 'SMTP_RETRY_MAX_DELAY_MS', }, }, + metricsEnabled: { + doc: 'Flag to enable UTM metrics for SMTP links', + format: Boolean, + default: true, + env: 'SMTP_METRICS_ENABLED', + }, }, bounces: { enabled: { diff --git a/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index 839f132e64b..fc6d343889d 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -40,6 +40,7 @@ const { gleanMetrics } = require('../lib/metrics/glean'); const Customs = require('../lib/customs'); const { ProfileClient } = require('@fxa/profile/client'); const { BackupCodeManager } = require('@fxa/accounts/two-factor'); +const { Bounces, EmailSender } = require('@fxa/accounts/email-sender'); const { DeleteAccountTasks, DeleteAccountTasksFactory, @@ -57,6 +58,11 @@ const { AccountManager } = require('@fxa/shared/account/account'); const { setupAccountDatabase } = require('@fxa/shared/db/mysql/account'); const { EmailCloudTaskManager } = require('../lib/email-cloud-tasks'); const { OtpUtils } = require('../lib/routes/utils/otp'); +const { FxaMailer } = require('../lib/senders/fxa-mailer'); +const { + EmailLinkBuilder, + NodeRendererBindings, +} = require('@fxa/accounts/email-renderer'); async function run(config) { Container.set(AppConfig, config); @@ -291,7 +297,11 @@ async function run(config) { }); Container.set(ProfileClient, profile); - const bounces = require('../lib/bounces')(config, database); + const bounces = new Bounces(config.smtp.bounces, { + // libs expectation for db is a bit simpler so we just pass through the + // existing function + emailBounces: { findByEmail: (email) => database.emailBounces(email) }, + }); const senders = await require('../lib/senders')(log, config, bounces, statsd); const glean = gleanMetrics(config); @@ -347,6 +357,21 @@ async function run(config) { const zendeskClient = require('../lib/zendesk-client').createZendeskClient( config ); + // mailer lib setup + const emailSender = new EmailSender(config.smtp, bounces, statsd, log); + const linkBuilderConfig = { + ...config.smtp, + ...config.links, + }; + const linkBuilder = new EmailLinkBuilder(linkBuilderConfig); + const fxaMailer = new FxaMailer( + emailSender, + linkBuilder, + config.smtp, + new NodeRendererBindings() + ); + Container.set(FxaMailer, fxaMailer); + const routes = require('../lib/routes')( log, serverPublicKeys, diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index 564d6f9e540..a426e316adb 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -651,6 +651,38 @@ const convictConf = convict({ default: 30000, env: 'SMTP_DNS_TIMEOUT', }, + retry: { + maxAttempts: { + doc: 'Maximum number of attempts for sending an email IF it fails. 1 means 1 additional attempt after the initial failure.', + format: 'int', + default: 3, + env: 'SMTP_RETRY_MAX_RETRIES', + }, + backOffMs: { + doc: `Number of milliseconds to exponentially back off when retrying sending an email.`, + format: 'int', + default: 200, + env: 'SMTP_RETRY_BACKOFF_MS', + }, + jitter: { + doc: 'Jitter factor (0-1) to add randomness to backoff timing. 0 = no jitter, 1 = up to 100% jitter.', + format: Number, + default: 0.3, + env: 'SMTP_RETRY_JITTER', + }, + maxDelayMs: { + doc: 'Maximum delay in milliseconds to cap the backoff at.', + format: 'int', + default: 1000 * 10, // 10 seconds maximum delay + env: 'SMTP_RETRY_MAX_DELAY_MS', + }, + }, + metricsEnabled: { + doc: 'Flag to enable UTM metrics for SMTP links', + format: Boolean, + default: true, + env: 'SMTP_METRICS_ENABLED', + }, }, maxEventLoopDelay: { doc: 'Max event-loop delay before which incoming requests are rejected', diff --git a/packages/fxa-auth-server/lib/routes/password.ts b/packages/fxa-auth-server/lib/routes/password.ts index 59f08011afc..db437588d6d 100644 --- a/packages/fxa-auth-server/lib/routes/password.ts +++ b/packages/fxa-auth-server/lib/routes/password.ts @@ -6,8 +6,15 @@ import { emailsMatch } from 'fxa-shared/email/helpers'; import { StatsD } from 'hot-shots'; import { Redis } from 'ioredis'; 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 { ConfigType } from '../../config'; import PASSWORD_DOCS from '../../docs/swagger/password-api'; @@ -21,6 +28,7 @@ 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'; const HEX_STRING = validators.HEX_STRING; @@ -59,6 +67,7 @@ module.exports = function ( authServerCacheRedis: Redis, statsd: StatsD ) { + const fxaMailer = Container.get(FxaMailer); const otpUtils = require('../../lib/routes/utils/otp').default(db, statsd); const otpRedisAdapter = new OtpRedisAdapter( authServerCacheRedis, @@ -1023,35 +1032,39 @@ 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; - await mailer.sendPasswordForgotOtpEmail(account.emails, account, { + const { to, cc } = splitEmails(account.emails); + const { time, date, acceptLanguage, timeZone } = + constructLocalTimeAndDateStrings( + request.app.acceptLanguage, + geoData.timeZone + ); + await fxaMailer.sendPasswordForgotOtpEmail({ code, - service, - acceptLanguage: request.app.acceptLanguage, + to, + cc, deviceId, flowId, flowBeginTime, - ip, - location: geoData.location, - timeZone: geoData.timeZone, - uaBrowser, - uaBrowserVersion, - uaOS, - uaOSVersion, - uaDeviceType, - uid: account.uid, + time, + date, + acceptLanguage, + timeZone, + sync: false, + device: formatUserAgentInfo(uaBrowser, uaOS, uaOSVersion), + location: { + city: geoData.location?.city, + country: geoData.location?.country, + stateCode: geoData.location?.state, + }, }); glean.resetPassword.otpEmailSent(request); @@ -1183,7 +1196,6 @@ module.exports = function ( const email = payload.email; const service = payload.service || request.query.service; - const ip = request.app.clientAddress; request.validateMetricsContext(); @@ -1225,33 +1237,44 @@ module.exports = function ( const geoData = request.app.geo; const { browser: uaBrowser, - browserVersion: uaBrowserVersion, os: uaOS, osVersion: uaOSVersion, - deviceType: uaDeviceType, } = request.app.ua; - await mailer.sendRecoveryEmail(emails, passwordForgotToken, { - emailToHashWith: passwordForgotToken.email, + const { to, cc } = splitEmails(emails); + + const { time, date, timeZone, acceptLanguage } = + constructLocalTimeAndDateStrings( + geoData.timeZone, + request.app.acceptLanguage + ); + + await fxaMailer.sendRecoveryEmail({ + uid: passwordForgotToken.uid, + to, + cc, + deviceId, + flowId, + flowBeginTime, + sync: false, + time, + date, + acceptLanguage, + timeZone, token: passwordForgotToken.data, code: passwordForgotToken.passCode, + emailToHashWith: passwordForgotToken.email, service: service, redirectTo: payload.redirectTo, resume: payload.resume, - acceptLanguage: request.app.acceptLanguage, - deviceId, - flowId, - flowBeginTime, - ip, - location: geoData.location, - timeZone: geoData.timeZone, - uaBrowser, - uaBrowserVersion, - uaOS, - uaOSVersion, - uaDeviceType, - uid: passwordForgotToken.uid, + device: formatUserAgentInfo(uaBrowser, uaOS, uaOSVersion), + location: { + city: geoData.location?.city || '', + country: geoData.location?.country || '', + stateCode: geoData.location?.state || '', + }, }); + await Promise.all([ request.emitMetricsEvent('password.forgot.send_code.completed'), glean.resetPassword.emailSent(request), diff --git a/packages/fxa-auth-server/lib/senders/fxa-mailer.ts b/packages/fxa-auth-server/lib/senders/fxa-mailer.ts new file mode 100644 index 00000000000..0425c538a34 --- /dev/null +++ b/packages/fxa-auth-server/lib/senders/fxa-mailer.ts @@ -0,0 +1,154 @@ +/* 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 * as renderer from '@fxa/accounts/email-renderer'; +import * as sender from '@fxa/accounts/email-sender'; + +import { FxaEmailRenderer } from '@fxa/accounts/email-renderer'; +import { ConfigType } from '../../config'; + +const SERVER = 'fxa-auth-server'; + +type EmailSenderOpts = { + acceptLanguage: string; + to: string; + cc?: string[]; + timeZone?: string; +}; + +type EmailFlowParams = { + deviceId?: string; + flowId?: string; + flowBeginTime?: number; +}; + +/** + * 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. + */ +type OmitCommonLinks = Omit; + +export class FxaMailer extends FxaEmailRenderer { + constructor( + private emailSender: sender.EmailSender, + private linkBuilder: renderer.EmailLinkBuilder, + private mailerConfig: ConfigType['smtp'], + bindings: renderer.NodeRendererBindings + ) { + super(bindings); + } + + async sendRecoveryEmail( + opts: EmailSenderOpts & + EmailFlowParams & { + uid: string; + token: string; + code: string; + emailToHashWith?: string; + service?: string; + redirectTo?: string; + resume?: string; + } & OmitCommonLinks & + OmitCommonLinks + ) { + const { template: name, version } = renderer.recovery; + + const link = new URL(this.linkBuilder.urls.initiatePasswordReset); + + this.linkBuilder.buildLinkWithQueryParamsAndUTM(link, name, { + uid: opts.uid, + token: opts.token, + code: opts.code, + email: opts.to, + }); + + if (opts.service) { + link.searchParams.set('service', opts.service); + } + if (opts.redirectTo) { + link.searchParams.set('redirectTo', opts.redirectTo); + } + if (opts.resume) { + link.searchParams.set('resume', opts.resume); + } + if (opts.emailToHashWith) { + link.searchParams.set('emailToHashWith', opts.emailToHashWith); + } + + const { privacyUrl, supportUrl } = this.linkBuilder.buildCommonLinks(name); + + const rendered = await this.renderRecovery( + { + ...opts, + link: link.toString(), + supportUrl, + }, + { + ...opts, + privacyUrl, + } + ); + + const headers = this.emailSender.buildHeaders({ + context: { ...opts, serverName: SERVER, language: opts.acceptLanguage }, + headers: { + 'X-Link': link.toString(), + 'X-Recovery-Code': opts.code, + }, + template: { + name, + version, + }, + }); + + return this.emailSender.send({ + to: opts.to, + cc: opts.cc, + from: this.mailerConfig.sender, + headers, + ...rendered, + }); + } + + /** + * Renders and sends the password forgot OTP email. + * @param opts + * @returns + */ + async sendPasswordForgotOtpEmail( + opts: EmailSenderOpts & + EmailFlowParams & + OmitCommonLinks & + OmitCommonLinks + ) { + const { template: name, version } = renderer.passwordForgotOtp; + + const { privacyUrl, supportUrl } = this.linkBuilder.buildCommonLinks(name); + + const rendered = await this.renderPasswordForgotOtp( + { ...opts, supportUrl }, + { ...opts, privacyUrl } + ); + + const headers = this.emailSender.buildHeaders({ + context: { ...opts, serverName: SERVER, language: opts.acceptLanguage }, + headers: { + 'x-password-forgot-otp': opts.code, + }, + template: { + name, + version, + }, + }); + + return this.emailSender.send({ + to: opts.to, + cc: opts.cc, + from: this.mailerConfig.sender, + headers, + ...rendered, + }); + } +} diff --git a/packages/fxa-auth-server/lib/types.ts b/packages/fxa-auth-server/lib/types.ts index 13d9fddf2d7..0fda7ed2a14 100644 --- a/packages/fxa-auth-server/lib/types.ts +++ b/packages/fxa-auth-server/lib/types.ts @@ -41,7 +41,7 @@ export interface AuthApp extends RequestApplicationState { countryCode: string; postalCode?: string; }; - [key: string]: any; + timeZone: string; }; } diff --git a/packages/fxa-auth-server/test/local/routes/password.js b/packages/fxa-auth-server/test/local/routes/password.js index 025f64014a5..75828091f53 100644 --- a/packages/fxa-auth-server/test/local/routes/password.js +++ b/packages/fxa-auth-server/test/local/routes/password.js @@ -16,9 +16,29 @@ const { AppError: error } = require('@fxa/accounts/errors'); const log = require('../../../lib/log'); const random = require('../../../lib/crypto/random'); const glean = mocks.mockGlean(); +const Container = require('typedi').Container; +const { FxaMailer } = require('../../../lib/senders/fxa-mailer'); const TEST_EMAIL = 'foo@gmail.com'; +function setupFxaMailerMock() { + const mockFxaMailer = { + sendRecoveryEmail: sinon.stub().resolves(), + sendPasswordForgotOtpEmail: sinon.stub().resolves(), + splitEmails: sinon.stub().returns({ to: TEST_EMAIL, cc: [] }), + helpers: { + constructLocalTimeString: sinon.stub().returns({ + timeNow: '12:00:00 PM (UTC)', + dateNow: 'Monday, Jan 1, 2024', + }), + }, + }; + + Container.set(FxaMailer, mockFxaMailer); + + return mockFxaMailer; +} + function makeRoutes(options = {}) { const config = options.config || { verifierVersion: 0, @@ -66,13 +86,19 @@ function runRoute(routes, name, request) { describe('/password', () => { let mockAccountEventsManager; + let mockFxaMailer; beforeEach(() => { + // mailer mock must be done before route creation/require + // otherwise it won't pickup the mock we define because + // of module caching + mockFxaMailer = setupFxaMailerMock(); mockAccountEventsManager = mocks.mockAccountEventsManager(); glean.resetPassword.emailSent.reset(); }); afterEach(() => { + Container.reset(); mocks.unMockAccountEventsManager(); }); @@ -88,7 +114,7 @@ describe('/password', () => { const mockDB = mocks.mockDB({ email: TEST_EMAIL, uid, - emailVerified: true + emailVerified: true, }); const mockMailer = mocks.mockMailer(); const mockMetricsContext = mocks.mockMetricsContext(); @@ -143,6 +169,7 @@ describe('/password', () => { '/password/forgot/send_otp', mockRequest ).then((response) => { + sinon.assert.calledOnce(mockFxaMailer.sendPasswordForgotOtpEmail); assert.equal( mockDB.accountRecord.callCount, 1, @@ -166,7 +193,7 @@ describe('/password', () => { 'passwordForgotSendOtp' ); - sinon.assert.calledOnce(mockMailer.sendPasswordForgotOtpEmail); + sinon.assert.calledOnce(mockFxaMailer.sendPasswordForgotOtpEmail); assert.equal(mockMetricsContext.setFlowCompleteSignal.callCount, 1); const args = mockMetricsContext.setFlowCompleteSignal.args[0]; @@ -240,7 +267,11 @@ describe('/password', () => { }); try { - await runRoute(passwordRoutes, '/password/forgot/send_otp', mockRequest); + await runRoute( + passwordRoutes, + '/password/forgot/send_otp', + mockRequest + ); assert.fail('should have thrown unknownAccount error'); } catch (err) { assert.equal(err.errno, 102, 'unknownAccount error'); @@ -526,17 +557,15 @@ describe('/password', () => { 'password.forgot.send_code.completed event was logged' ); - assert.equal( - mockMailer.sendRecoveryEmail.callCount, - 1, - 'mailer.sendRecoveryEmail was called once' - ); - args = mockMailer.sendRecoveryEmail.args[0]; - assert.equal(args[2].location.city, 'Mountain View'); - assert.equal(args[2].location.country, 'United States'); - assert.equal(args[2].timeZone, 'America/Los_Angeles'); - assert.equal(args[2].uid, uid); - assert.equal(args[2].deviceId, 'wibble'); + const mailerArgs = mockFxaMailer.sendRecoveryEmail.args[0]; + // strong typing here would be great. We're making sure the mailer is + // called with the required properties for fxa-mailer.sendRecoveryEmail, so we could + // export that type eventually and use it here for mailerArgs[1] so we're not guessing + assert.equal(mailerArgs[0].to, TEST_EMAIL); + assert.equal(mailerArgs[0].location.city, 'Mountain View'); + assert.equal(mailerArgs[0].location.country, 'United States'); + assert.equal(mailerArgs[0].timeZone, 'America/Los_Angeles'); + assert.equal(mailerArgs[0].deviceId, 'wibble'); sinon.assert.calledOnceWithExactly( glean.resetPassword.emailSent, diff --git a/packages/fxa-shared/lib/user-agent.ts b/packages/fxa-shared/lib/user-agent.ts index 85e503d2482..1216bc5dee7 100644 --- a/packages/fxa-shared/lib/user-agent.ts +++ b/packages/fxa-shared/lib/user-agent.ts @@ -217,10 +217,43 @@ function marshallDeviceType(formFactor: string) { return 'mobile'; } +/** + * Format user agent info safely for email templates. + * Returns undefined if no valid browser or OS info is available. + * + * @param uaBrowser - Browser name from user agent + * @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 || ''); + + return !safeBrowser && !safeOS + ? undefined + : { + uaBrowser: safeBrowser || '', + uaOS: safeOS || '', + uaOSVersion: safeOSVersion || '', + }; +}; + export default { parse, parseToScalars, isToVersionStringSupported, + formatUserAgentInfo, safeName: safeReturnName, safeVersion: safeReturnVersion, };