Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions libs/accounts/email-renderer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export type TemplateData = UserDeviceTemplateData &
primaryEmail?: string;
date?: string;
time?: string;
//acceptLanguage: string;
};
140 changes: 140 additions & 0 deletions libs/accounts/email-renderer/src/renderer/email-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
101 changes: 101 additions & 0 deletions libs/accounts/email-renderer/src/renderer/email-helpers.ts
Original file line number Diff line number Diff line change
@@ -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);
};
Loading