Port breach alert email to new email template
This commit is contained in:
Родитель
1a147a2690
Коммит
a4cb074982
|
@ -168,7 +168,7 @@ then add it to the (emulated) pubsub queue.
|
|||
### This pubsub queue will be consumed by this cron job, which is responsible for looking up and emailing impacted users:
|
||||
|
||||
```sh
|
||||
npm run dev:cron:breach-alerts
|
||||
NODE_ENV="development" npm run dev:cron:breach-alerts
|
||||
```
|
||||
|
||||
### Emails
|
||||
|
|
|
@ -82,11 +82,14 @@ email-verify-subhead = Verify your email to start protecting your data after a b
|
|||
email-verify-simply-click = Simply click the link below to finish verifying your account.
|
||||
|
||||
## Breach report
|
||||
## Variables:
|
||||
## $email-address (string) - Email address
|
||||
|
||||
email-breach-summary = Here’s your data breach summary
|
||||
# Variables:
|
||||
# $email-address (string) - Email address, bolded
|
||||
email-breach-detected = Search results for your { $email-address } account have detected that your email may have been exposed. We recommend you act now to resolve this breach.
|
||||
# Variables:
|
||||
# $email-address (string) - Email address
|
||||
email-breach-detected-2 = Search results for your <b>{ $email-address }</b> account have detected that your email may have been exposed. We recommend you act now to resolve this breach.
|
||||
email-dashboard-cta = Go to Dashboard
|
||||
|
||||
## Breach alert
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"dev": "npm run build-nimbus && next dev --port=6060",
|
||||
"dev:cron:first-data-broker-removal-fixed": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/firstDataBrokerRemovalFixed.tsx",
|
||||
"dev:cron:monthly-activity": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/monthlyActivity.tsx",
|
||||
"dev:cron:breach-alerts": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/emailBreachAlerts.ts",
|
||||
"dev:cron:breach-alerts": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/emailBreachAlerts.tsx",
|
||||
"dev:cron:db-delete-unverified-subscribers": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/deleteUnverifiedSubscribers.ts",
|
||||
"dev:cron:db-pull-breaches": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/syncBreaches.ts",
|
||||
"dev:cron:remote-settings-pull-breaches": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/updateBreachesInRemoteSettings.ts",
|
||||
|
|
|
@ -197,12 +197,13 @@ export function createRandomHibpListing(
|
|||
Name: name,
|
||||
PwnCount: faker.number.int(),
|
||||
Title: title,
|
||||
FaviconUrl: faker.helpers.maybe(() =>
|
||||
faker.image.url({
|
||||
height: faker.number.int({ min: 20, max: 36 }),
|
||||
width: faker.number.int({ min: 20, max: 36 }),
|
||||
}),
|
||||
),
|
||||
FaviconUrl: faker.helpers.maybe(() => {
|
||||
const dimension = faker.number.int({ min: 20, max: 36 });
|
||||
return faker.image.url({
|
||||
height: dimension,
|
||||
width: dimension,
|
||||
});
|
||||
}),
|
||||
...fixedData,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { useState } from "react";
|
|||
import Link from "next/link";
|
||||
import styles from "./EmailTrigger.module.scss";
|
||||
import {
|
||||
triggerBreachAlert,
|
||||
triggerFirstDataBrokerRemovalFixed,
|
||||
triggerMonthlyActivity,
|
||||
triggerVerificationEmail,
|
||||
|
@ -22,10 +23,11 @@ export const EmailTrigger = (props: Props) => {
|
|||
const [selectedEmailAddress, setSelectedEmailAddress] = useState(
|
||||
props.emailAddresses[0],
|
||||
);
|
||||
const [isSendingVerification, setIssSendingVerification] = useState(false);
|
||||
const [isSendingVerification, setIsSendingVerification] = useState(false);
|
||||
const [isSendingBreachAlert, setIsSendingBreachAlert] = useState(false);
|
||||
const [
|
||||
isSendingMonthlyActivityOverview,
|
||||
setIssSendingMonthlyActivityOverview,
|
||||
setIsSendingMonthlyActivityOverview,
|
||||
] = useState(false);
|
||||
const [firstDataBrokerRemovalFixed, setFirstDataBrokerRemovalFixed] =
|
||||
useState(false);
|
||||
|
@ -60,9 +62,9 @@ export const EmailTrigger = (props: Props) => {
|
|||
variant="primary"
|
||||
isLoading={isSendingVerification}
|
||||
onPress={() => {
|
||||
setIssSendingVerification(true);
|
||||
setIsSendingVerification(true);
|
||||
void triggerVerificationEmail(selectedEmailAddress).then(() => {
|
||||
setIssSendingVerification(false);
|
||||
setIsSendingVerification(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -72,14 +74,26 @@ export const EmailTrigger = (props: Props) => {
|
|||
variant="primary"
|
||||
isLoading={isSendingMonthlyActivityOverview}
|
||||
onPress={() => {
|
||||
setIssSendingMonthlyActivityOverview(true);
|
||||
setIsSendingMonthlyActivityOverview(true);
|
||||
void triggerMonthlyActivity(selectedEmailAddress).then(() => {
|
||||
setIssSendingMonthlyActivityOverview(false);
|
||||
setIsSendingMonthlyActivityOverview(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Monthly activity overview
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isLoading={isSendingBreachAlert}
|
||||
onPress={() => {
|
||||
setIsSendingBreachAlert(true);
|
||||
void triggerBreachAlert(selectedEmailAddress).then(() => {
|
||||
setIsSendingBreachAlert(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Breach alert
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isLoading={firstDataBrokerRemovalFixed}
|
||||
|
|
|
@ -23,7 +23,11 @@ import { getCountryCode } from "../../../../../functions/server/getCountryCode";
|
|||
import { headers } from "next/headers";
|
||||
import { getLatestOnerepScanResults } from "../../../../../../db/tables/onerep_scans";
|
||||
import { FirstDataBrokerRemovalFixed } from "../../../../../../emails/templates/firstDataBrokerRemovalFixed/FirstDataBrokerRemovalFixed";
|
||||
import { createRandomScanResult } from "../../../../../../apiMocks/mockData";
|
||||
import {
|
||||
createRandomHibpListing,
|
||||
createRandomScanResult,
|
||||
} from "../../../../../../apiMocks/mockData";
|
||||
import { BreachAlertEmail } from "../../../../../../emails/templates/breachAlert/BreachAlertEmail";
|
||||
|
||||
async function getAdminSubscriber(): Promise<SubscriberRow | null> {
|
||||
const session = await getServerSession();
|
||||
|
@ -122,6 +126,27 @@ export async function triggerMonthlyActivity(emailAddress: string) {
|
|||
);
|
||||
}
|
||||
|
||||
export async function triggerBreachAlert(emailAddress: string) {
|
||||
const session = await getServerSession();
|
||||
const subscriber = await getAdminSubscriber();
|
||||
if (!subscriber || !session?.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const l10n = getL10n();
|
||||
|
||||
await send(
|
||||
emailAddress,
|
||||
l10n.getString("breach-alert-subject"),
|
||||
<BreachAlertEmail
|
||||
breach={createRandomHibpListing()}
|
||||
breachedEmail={emailAddress}
|
||||
utmCampaignId="breach-alert"
|
||||
l10n={l10n}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
export async function triggerFirstDataBrokerRemovalFixed(emailAddress: string) {
|
||||
const l10n = getL10n();
|
||||
const randomScanResult = createRandomScanResult({ status: "removed" });
|
||||
|
|
|
@ -34,6 +34,8 @@ export const getL10nBundles: GetL10nBundles = createGetL10nBundles({
|
|||
availableLocales: readdirSync(resolve(rootDir, `./locales/`)),
|
||||
// TODO: Make this optional in `createGetL10nBundles`, which would then make
|
||||
// it required in the newly-created function:
|
||||
// We don't have tests for different locales.
|
||||
/* c8 ignore next */
|
||||
getAcceptLangHeader: () => "en",
|
||||
loadLocaleFiles: (locale) => {
|
||||
const referenceStringsPath = resolve(rootDir, `./locales/${locale}/`);
|
||||
|
@ -72,7 +74,7 @@ function getRootDir(currentDir = dirname(fileURLToPath(import.meta.url))) {
|
|||
return getRootDir(resolve(currentDir, "../"));
|
||||
}
|
||||
|
||||
export const getL10n: GetL10n = createGetL10n({
|
||||
const getL10n: GetL10n = createGetL10n({
|
||||
getL10nBundles: getL10nBundles,
|
||||
ReactLocalization: ReactLocalization,
|
||||
parseMarkup: parseMarkup,
|
||||
|
|
|
@ -57,6 +57,10 @@ export const EmailFooter = (props: Props) => {
|
|||
<mj-column>
|
||||
<mj-text font-size="14px" font-weight="400" align="center">
|
||||
{l10n.getFragment(
|
||||
// These lines get covered by the FirstDataBrokerRemovalFixed test,
|
||||
// but for some reason get marked as uncovered again once the
|
||||
// `src/scripts/cronjobs/emailBreachAlerts.test.ts` tests are run:
|
||||
/* c8 ignore next 2 */
|
||||
props.isOneTimeEmail
|
||||
? "email-footer-reason-subscriber-one-time"
|
||||
: "email-footer-reason-subscriber",
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/* 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 type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FC } from "react";
|
||||
import { Props, BreachAlertEmail } from "./BreachAlertEmail";
|
||||
import { StorybookEmailRenderer } from "../../StorybookEmailRenderer";
|
||||
import { getL10n } from "../../../app/functions/l10n/storybookAndJest";
|
||||
import { createRandomHibpListing } from "../../../apiMocks/mockData";
|
||||
|
||||
const meta: Meta<FC<Props>> = {
|
||||
title: "Emails/Breach alert",
|
||||
component: (props: Props) => (
|
||||
<StorybookEmailRenderer>
|
||||
<BreachAlertEmail {...props} />
|
||||
</StorybookEmailRenderer>
|
||||
),
|
||||
args: {
|
||||
l10n: getL10n("en"),
|
||||
utmCampaignId: "breach-alert",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<FC<Props>>;
|
||||
|
||||
export const BreachAlertEmailStory: Story = {
|
||||
name: "Breach alert",
|
||||
args: {
|
||||
breach: createRandomHibpListing(),
|
||||
breachedEmail: "example@example.com",
|
||||
},
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
/* 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 { it, expect } from "@jest/globals";
|
||||
import { composeStory } from "@storybook/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Meta, { BreachAlertEmailStory } from "./BreachAlertEmail.stories";
|
||||
import { createRandomHibpListing } from "../../../apiMocks/mockData";
|
||||
|
||||
it("lists compromised data", () => {
|
||||
const ComposedEmail = composeStory(BreachAlertEmailStory, Meta);
|
||||
render(
|
||||
<ComposedEmail
|
||||
breach={createRandomHibpListing({
|
||||
DataClasses: ["email-addresses", "passwords"],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const breachCard = screen.getByText("Compromised data:");
|
||||
expect(breachCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays a breach icon if available", () => {
|
||||
const ComposedEmail = composeStory(BreachAlertEmailStory, Meta);
|
||||
const { container } = render(
|
||||
<ComposedEmail
|
||||
breach={createRandomHibpListing({
|
||||
FaviconUrl: "https://example.com/image.webp",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector("img[src='https://example.com/image.webp']"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not display a breach icon if none is available", () => {
|
||||
const ComposedEmail = composeStory(BreachAlertEmailStory, Meta);
|
||||
const { container } = render(
|
||||
<ComposedEmail
|
||||
breach={createRandomHibpListing({ FaviconUrl: undefined })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector("img:not([src^='http://localhost'])"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
|
@ -0,0 +1,143 @@
|
|||
/* 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 React from "react";
|
||||
import { ExtendedReactLocalization } from "../../../app/functions/l10n";
|
||||
import { EmailFooter } from "../EmailFooter";
|
||||
import { EmailHeader } from "../EmailHeader";
|
||||
import { HibpLikeDbBreach } from "../../../utils/hibp";
|
||||
import { formatDate } from "../../../utils/formatDate";
|
||||
import { getLocale } from "../../../app/functions/universal/getLocale";
|
||||
|
||||
export type Props = {
|
||||
l10n: ExtendedReactLocalization;
|
||||
breach: HibpLikeDbBreach;
|
||||
breachedEmail: string;
|
||||
utmCampaignId: string;
|
||||
};
|
||||
|
||||
export const BreachAlertEmail = (props: Props) => {
|
||||
const l10n = props.l10n;
|
||||
const listFormatter = new Intl.ListFormat(getLocale(l10n));
|
||||
|
||||
return (
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-preview>{l10n.getString("email-spotted-new-breach")}</mj-preview>
|
||||
<mj-style>
|
||||
{`
|
||||
.metadata-heading {
|
||||
color: #5e5e72;
|
||||
}
|
||||
img.breach-logo {
|
||||
display: inline-block;
|
||||
}
|
||||
`}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<EmailHeader l10n={l10n} utm_campaign={props.utmCampaignId} />
|
||||
<mj-section padding="20px">
|
||||
<mj-column>
|
||||
<mj-text align="center" font-size="16px" line-height="24px">
|
||||
{l10n.getFragment("email-breach-detected-2", {
|
||||
vars: { "email-address": props.breachedEmail },
|
||||
elems: { b: <b /> },
|
||||
})}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-wrapper border="1px solid #eee" text-align="center" padding="0">
|
||||
<mj-section background-color="#eee">
|
||||
<mj-column vertical-align="middle">
|
||||
<mj-text
|
||||
font-size="18px"
|
||||
line-height="24px"
|
||||
align="center"
|
||||
height="32px"
|
||||
>
|
||||
<BreachLogo breach={props.breach} />
|
||||
<span style={{ paddingInlineStart: "4px" }}>
|
||||
{props.breach.Title}
|
||||
</span>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding="20px">
|
||||
<mj-column>
|
||||
<mj-text align="center" font-size="16px" padding="24px">
|
||||
<p className="metadata-heading">
|
||||
{l10n.getString("breach-added-label")}
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
{formatDate(props.breach.AddedDate, getLocale(props.l10n))}
|
||||
</b>
|
||||
</p>
|
||||
{
|
||||
// These lines get covered by the BreachAlertEmail.test.tsx tests,
|
||||
// but for some reason get marked as uncovered again once the
|
||||
// `src/scripts/cronjobs/emailBreachAlerts.test.ts` tests are run:
|
||||
/* c8 ignore next 2 */
|
||||
Array.isArray(props.breach.DataClasses) &&
|
||||
props.breach.DataClasses.length > 0 && (
|
||||
<>
|
||||
<p className="metadata-heading">
|
||||
{l10n.getString("compromised-data")}
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
{listFormatter.format(
|
||||
props.breach.DataClasses.map((classKey) =>
|
||||
l10n.getString(classKey),
|
||||
),
|
||||
)}
|
||||
</b>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
<mj-section padding="20px">
|
||||
<mj-column>
|
||||
<mj-button
|
||||
href={`${process.env.SERVER_URL}/user/dashboard/action-needed?utm_source=monitor-product&utm_medium=email&utm_campaign=${props.utmCampaignId}&utm_content=view-your-dashboard-us`}
|
||||
background-color="#0060DF"
|
||||
font-weight={600}
|
||||
font-size="15px"
|
||||
line-height="22px"
|
||||
>
|
||||
{l10n.getString("email-dashboard-cta")}
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<EmailFooter l10n={l10n} utm_campaign={props.utmCampaignId} />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
);
|
||||
};
|
||||
|
||||
const BreachLogo = (props: { breach: HibpLikeDbBreach }) => {
|
||||
// These lines get covered by the BreachAlertEmail.test.tsx tests,
|
||||
// but for some reason get marked as uncovered again once the
|
||||
// `src/scripts/cronjobs/emailBreachAlerts.test.ts` tests are run:
|
||||
/* c8 ignore next 12 */
|
||||
if (props.breach.FaviconUrl) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={props.breach.FaviconUrl}
|
||||
alt=""
|
||||
width="32px"
|
||||
height="32px"
|
||||
className="breach-logo"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
|
@ -65,6 +65,31 @@ jest.mock("../../utils/fluent.js", () => {
|
|||
};
|
||||
});
|
||||
|
||||
jest.mock("../../db/tables/featureFlags", () => {
|
||||
return {
|
||||
getEnabledFeatureFlags: jest.fn(() => Promise.resolve([])),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../../app/functions/l10n/parseMarkup", () => {
|
||||
return {
|
||||
parseMarkup: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../../app/functions/server/logging", () => {
|
||||
class Logging {
|
||||
info(message: string, details: object) {
|
||||
console.info(message, details);
|
||||
}
|
||||
}
|
||||
|
||||
const logger = new Logging();
|
||||
return {
|
||||
logger,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../../emails/email2022.js", () => {
|
||||
return {
|
||||
getTemplate: jest.fn(),
|
||||
|
@ -274,6 +299,51 @@ test("processes valid messages", async () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("rendering the new template if the `RedesignedEmails` flag is enabled", async () => {
|
||||
const mockedFeatureFlagModule: any = jest.requireMock(
|
||||
"../../db/tables/featureFlags",
|
||||
);
|
||||
mockedFeatureFlagModule.getEnabledFeatureFlags.mockResolvedValue([
|
||||
"RedesignedEmails",
|
||||
]);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, "log")
|
||||
.mockImplementation(() => undefined);
|
||||
// It's not clear if the calls to console.info are important enough to remain,
|
||||
// but since they were already there when adding the "no logs" rule in tests,
|
||||
// I'm respecting Chesterton's Fence and leaving them in place for now:
|
||||
jest.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
const emailMod = await import("../../utils/email.js");
|
||||
const sendEmail = emailMod.sendEmail as jest.Mock<
|
||||
(typeof emailMod)["sendEmail"]
|
||||
>;
|
||||
|
||||
const mockedUtilsHibp: any = jest.requireMock("../../utils/hibp");
|
||||
mockedUtilsHibp.getBreachByName.mockReturnValue({
|
||||
IsVerified: true,
|
||||
Domain: "test1",
|
||||
IsFabricated: false,
|
||||
IsSpamList: false,
|
||||
});
|
||||
|
||||
const receivedMessages = buildReceivedMessages({
|
||||
breachName: "test1",
|
||||
hashPrefix: "test-prefix1",
|
||||
hashSuffixes: ["test-suffix1"],
|
||||
});
|
||||
|
||||
const { poll } = await import("./emailBreachAlerts");
|
||||
|
||||
await poll(subClient, receivedMessages);
|
||||
expect(subClient.acknowledge).toHaveBeenCalledTimes(1);
|
||||
expect(sendEmail).toHaveBeenCalledTimes(1);
|
||||
const emailBody = sendEmail.mock.calls[0][2];
|
||||
expect(emailBody).toContain("Questions about Mozilla Monitor?");
|
||||
expect(consoleLog).toHaveBeenCalledWith(
|
||||
'Received message: {"breachName":"test1","hashPrefix":"test-prefix1","hashSuffixes":["test-suffix1"]}',
|
||||
);
|
||||
});
|
||||
|
||||
test("skipping email when subscriber id exists in email_notifications table", async () => {
|
||||
const consoleLog = jest
|
||||
.spyOn(console, "log")
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
* 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 React from "react";
|
||||
import Sentry from "@sentry/nextjs";
|
||||
import { acceptedLanguages, negotiateLanguages } from "@fluent/langneg";
|
||||
import { localStorage } from "../../utils/localStorage.js";
|
||||
|
@ -40,6 +41,11 @@ import {
|
|||
getAllBreachesFromDb,
|
||||
knexHibp,
|
||||
} from "../../utils/hibp";
|
||||
import { getEnabledFeatureFlags } from "../../db/tables/featureFlags";
|
||||
import { renderEmail } from "../../emails/renderEmail";
|
||||
import { BreachAlertEmail } from "../../emails/templates/breachAlert/BreachAlertEmail";
|
||||
import { getEmailL10n } from "../../app/functions/l10n/cronjobs";
|
||||
import { sanitizeSubscriberRow } from "../../app/functions/server/sanitize";
|
||||
|
||||
const SENTRY_SLUG = "cron-breach-alerts";
|
||||
|
||||
|
@ -277,13 +283,26 @@ export async function poll(
|
|||
notificationType: "incident",
|
||||
});
|
||||
|
||||
const emailTemplate = getTemplate(
|
||||
data,
|
||||
breachAlertEmailPartial,
|
||||
);
|
||||
const subject = getMessage("breach-alert-subject");
|
||||
const enabledFlags = await getEnabledFeatureFlags({
|
||||
email: recipient.primary_email,
|
||||
});
|
||||
const l10n = getEmailL10n(sanitizeSubscriberRow(recipient));
|
||||
const subject = l10n.getString("breach-alert-subject");
|
||||
|
||||
await sendEmail(data.recipientEmail, subject, emailTemplate);
|
||||
await sendEmail(
|
||||
data.recipientEmail,
|
||||
subject,
|
||||
enabledFlags.includes("RedesignedEmails")
|
||||
? renderEmail(
|
||||
<BreachAlertEmail
|
||||
l10n={l10n}
|
||||
breach={breachAlert}
|
||||
breachedEmail={breachedEmail}
|
||||
utmCampaignId={utmCampaignId}
|
||||
/>,
|
||||
)
|
||||
: getTemplate(data, breachAlertEmailPartial),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to add email notification to table: ", e);
|
||||
setTimeout(process.exit, 1000);
|
|
@ -5,8 +5,8 @@
|
|||
// TODO: Add unit test when changing this code:
|
||||
/* c8 ignore next 8 */
|
||||
|
||||
function formatDate(date: string, locales: string): string {
|
||||
const jsDate = new Date(date);
|
||||
function formatDate(date: string | Date, locales: string): string {
|
||||
const jsDate = typeof date === "string" ? new Date(date) : date;
|
||||
/** @type {{ year: 'numeric', month: 'long', day: 'numeric' }} */
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
|
|
Загрузка…
Ссылка в новой задаче