Port breach alert email to new email template

This commit is contained in:
Vincent 2024-07-31 14:36:27 +02:00 коммит произвёл Vincent
Родитель 1a147a2690
Коммит a4cb074982
14 изменённых файлов: 392 добавлений и 26 удалений

Просмотреть файл

@ -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 = Heres 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 = {