Skip to content

Commit

Permalink
Port breach alert email to new email template
Browse files Browse the repository at this point in the history
  • Loading branch information
Vinnl committed Jul 31, 2024
1 parent 7b08bd4 commit 6abb522
Show file tree
Hide file tree
Showing 12 changed files with 317 additions and 19 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions locales/en/email-strings.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 7 additions & 6 deletions src/apiMocks/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useState } from "react";
import Link from "next/link";
import styles from "./EmailTrigger.module.scss";
import {
triggerBreachAlert,
triggerFirstDataBrokerRemovalFixed,
triggerMonthlyActivity,
triggerVerificationEmail,
Expand All @@ -23,6 +24,7 @@ export const EmailTrigger = (props: Props) => {
props.emailAddresses[0],
);
const [isSendingVerification, setIssSendingVerification] = useState(false);
const [isSendingBreachAlert, setIssSendingBreachAlert] = useState(false);
const [
isSendingMonthlyActivityOverview,
setIssSendingMonthlyActivityOverview,
Expand Down Expand Up @@ -80,6 +82,18 @@ export const EmailTrigger = (props: Props) => {
>
Monthly activity overview
</Button>
<Button
variant="primary"
isLoading={isSendingBreachAlert}
onPress={() => {
setIssSendingBreachAlert(true);
void triggerBreachAlert(selectedEmailAddress).then(() => {
setIssSendingBreachAlert(false);
});
}}
>
Breach alert
</Button>
<Button
variant="primary"
isLoading={firstDataBrokerRemovalFixed}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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" });
Expand Down
34 changes: 34 additions & 0 deletions src/emails/templates/breachAlert/BreachAlertEmail.stories.tsx
Original file line number Diff line number Diff line change
@@ -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",
},
};
45 changes: 45 additions & 0 deletions src/emails/templates/breachAlert/BreachAlertEmail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* 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 />);

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();
});
133 changes: 133 additions & 0 deletions src/emails/templates/breachAlert/BreachAlertEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* 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>
{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 }) => {
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;
};
25 changes: 25 additions & 0 deletions src/scripts/cronjobs/emailBreachAlerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading

0 comments on commit 6abb522

Please sign in to comment.