();
+
+ useEffect(() => {
+ getAttachedClientsAction()
+ .then((attachedClients) => {
+ setData(attachedClients);
+ })
+ .catch((error) => {
+ console.error("Could not get attached clients", error);
+ });
+ }, []);
+
+ return data ? (
+ {JSON.stringify(data, null, 2)}
+ ) : (
+ "No data available"
+ );
+};
diff --git a/src/app/(proper_react)/(redesign)/(authenticated)/admin/fxa/actions.tsx b/src/app/(proper_react)/(redesign)/(authenticated)/admin/fxa/actions.tsx
new file mode 100644
index 0000000000..afe06de11a
--- /dev/null
+++ b/src/app/(proper_react)/(redesign)/(authenticated)/admin/fxa/actions.tsx
@@ -0,0 +1,36 @@
+/* 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/. */
+
+"use server";
+
+import { notFound } from "next/navigation";
+import { getAttachedClients } from "../../../../../../utils/fxa";
+import { getServerSession } from "../../../../../functions/server/getServerSession";
+import { isAdmin } from "../../../../../api/utils/auth";
+import { logger } from "@sentry/utils";
+import { captureException } from "@sentry/node";
+
+export async function getAttachedClientsAction() {
+ const session = await getServerSession();
+
+ if (
+ !session?.user?.email ||
+ !isAdmin(session.user.email) ||
+ process.env.APP_ENV === "production"
+ ) {
+ return notFound();
+ }
+
+ try {
+ const attachedClients = await getAttachedClients(
+ session?.user.subscriber?.fxa_access_token ?? "",
+ );
+ return attachedClients;
+ } catch (error) {
+ captureException(error);
+ logger.error("Could not get attached clients", {
+ error: JSON.stringify(error),
+ });
+ }
+}
diff --git a/src/app/(proper_react)/(redesign)/(authenticated)/admin/fxa/page.tsx b/src/app/(proper_react)/(redesign)/(authenticated)/admin/fxa/page.tsx
new file mode 100644
index 0000000000..62f362f157
--- /dev/null
+++ b/src/app/(proper_react)/(redesign)/(authenticated)/admin/fxa/page.tsx
@@ -0,0 +1,22 @@
+/* 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 { getServerSession } from "../../../../../functions/server/getServerSession";
+import { notFound } from "next/navigation";
+import { isAdmin } from "../../../../../api/utils/auth";
+import { AttachedClients } from "./AttachedClients";
+
+export default async function DevPage() {
+ const session = await getServerSession();
+
+ if (
+ !session?.user?.email ||
+ !isAdmin(session.user.email) ||
+ process.env.APP_ENV === "productions"
+ ) {
+ return notFound();
+ }
+
+ return ;
+}
diff --git a/src/app/(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/Dashboard.test.tsx b/src/app/(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/Dashboard.test.tsx
index a561343834..2bc43a344b 100644
--- a/src/app/(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/Dashboard.test.tsx
+++ b/src/app/(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/Dashboard.test.tsx
@@ -3645,7 +3645,7 @@ describe("CSAT survey banner", () => {
expect(answerButton).not.toBeInTheDocument();
});
- it("displays the removal time estimates CSAT survey on the “fixed” tab for users on the treatment branch", () => {
+ it("displays the removal time estimates CSAT survey on the “fixed” tab to Plus users on the treatment branch", () => {
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
@@ -3669,7 +3669,7 @@ describe("CSAT survey banner", () => {
expect(answerButton).toBeInTheDocument();
});
- it("displays the removal time estimates CSAT survey on the “fixed” tab for users on the control branch", () => {
+ it("displays the removal time estimates CSAT survey on the “fixed” tab to Plus users on the control branch", () => {
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
@@ -3693,7 +3693,7 @@ describe("CSAT survey banner", () => {
expect(answerButton).toBeInTheDocument();
});
- it("does not display the removal time estimates CSAT survey on the ”action needed” tab for users on the treatment branch", () => {
+ it("does not display the removal time estimates CSAT survey on the “action needed” tab to Plus users on the treatment branch", () => {
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
@@ -3705,7 +3705,31 @@ describe("CSAT survey banner", () => {
experimentData={{
...defaultExperimentData,
"data-broker-removal-time-estimates": {
- enabled: false,
+ enabled: true,
+ },
+ }}
+ />,
+ );
+
+ const answerButton = screen.queryByRole("button", {
+ name: "Neutral",
+ });
+ expect(answerButton).not.toBeInTheDocument();
+ });
+
+ it("does not display the removal time estimates CSAT survey on the “fixed” tab to free users on the treatment branch", () => {
+ const ComposedDashboard = composeStory(
+ DashboardUsNoPremiumUnresolvedScanNoBreaches,
+ Meta,
+ );
+ render(
+ ,
diff --git a/src/app/components/client/ExposuresFilter.test.tsx b/src/app/components/client/ExposuresFilter.test.tsx
index 7960396c36..279cfbed21 100644
--- a/src/app/components/client/ExposuresFilter.test.tsx
+++ b/src/app/components/client/ExposuresFilter.test.tsx
@@ -45,11 +45,12 @@ it("shows and hides the exposure type explainer", async () => {
expect(explainerDialog).not.toBeInTheDocument();
});
-it("shows and hides the removal time explainer dialog by clicking the “Got it” button", async () => {
+it("shows and hides the removal time explainer dialog by clicking the “Got it” button to Plus subscribers", async () => {
const user = userEvent.setup();
const ExposuresFilter = composeStory(ExposuresFilterDefault, Meta);
render(
{
+it("shows and hides the removal time explainer dialog by clicking the close button to Plus subscribers", async () => {
const user = userEvent.setup();
const ExposuresFilter = composeStory(ExposuresFilterDefault, Meta);
render(
{l10n.getString("dashboard-exposures-filter-date-found")}
- {enabledFeatureFlags.includes("DataBrokerRemovalTimeEstimateLabel") &&
+ {isPlusSubscriber &&
+ enabledFeatureFlags.includes(
+ "DataBrokerRemovalTimeEstimateLabel",
+ ) &&
experimentData["data-broker-removal-time-estimates"].enabled && (
{l10n.getString(
diff --git a/src/app/components/client/csat_survey/surveys/removalTimeEstimates.ts b/src/app/components/client/csat_survey/surveys/removalTimeEstimates.ts
index a07091331c..f3a6562fef 100644
--- a/src/app/components/client/csat_survey/surveys/removalTimeEstimates.ts
+++ b/src/app/components/client/csat_survey/surveys/removalTimeEstimates.ts
@@ -18,11 +18,6 @@ const surveyData: SurveyData = {
},
],
variations: [
- {
- id: "free-user",
- showForUser: ["free-user"],
- showOnTab: ["fixed"],
- },
{
id: "plus-user",
showForUser: ["plus-user"],
diff --git a/src/app/components/client/exposure_card/ExposureCard.test.tsx b/src/app/components/client/exposure_card/ExposureCard.test.tsx
index 7f8e801747..9d1bfbf8fa 100644
--- a/src/app/components/client/exposure_card/ExposureCard.test.tsx
+++ b/src/app/components/client/exposure_card/ExposureCard.test.tsx
@@ -148,7 +148,19 @@ describe("DataBreachCard", () => {
it("does not show the estimated removal time if the feature flag `DataBrokerRemovalTimeEstimates` is disabled", () => {
const ComposedExposureCard = composeStory(DataBrokerActionNeeded, Meta);
- render();
+ render();
+
+ const removalTimeTitle = screen.queryByText("Removal time");
+ expect(removalTimeTitle).not.toBeInTheDocument();
+ });
+
+ it("does not show the estimated removal time for free users", () => {
+ const ComposedExposureCard = composeStory(DataBrokerActionNeeded, Meta);
+ render(
+ ,
+ );
const removalTimeTitle = screen.queryByText("Removal time");
expect(removalTimeTitle).not.toBeInTheDocument();
@@ -168,11 +180,12 @@ describe("DataBreachCard", () => {
label: "181+ days",
},
])(
- "shows a label with the estimated removal time if available: %s",
+ "shows a label with the estimated removal time if available to Plus subscribers: %s",
({ removalTime, label }) => {
const ComposedExposureCard = composeStory(DataBrokerActionNeeded, Meta);
render(
,
@@ -185,10 +198,11 @@ describe("DataBreachCard", () => {
},
);
- it("shows a label displaying “unknown” if the removal time is not available", () => {
+ it("shows a label displaying “unknown” if the removal time is not available to Plus subscribers", () => {
const ComposedExposureCard = composeStory(DataBrokerActionNeeded, Meta);
render(
,
);
@@ -199,10 +213,11 @@ describe("DataBreachCard", () => {
expect(removalTimeLabel).toBeInTheDocument();
});
- it("shows a label displaying “N/A” on data breach cards", () => {
+ it("shows a label displaying “N/A” on data breach cards to Plus subscribers", () => {
const ComposedExposureCard = composeStory(DataBreachActionNeeded, Meta);
render(
,
);
diff --git a/src/app/components/client/exposure_card/ScanResultCard.tsx b/src/app/components/client/exposure_card/ScanResultCard.tsx
index 3f0196b4a5..890fb70520 100644
--- a/src/app/components/client/exposure_card/ScanResultCard.tsx
+++ b/src/app/components/client/exposure_card/ScanResultCard.tsx
@@ -266,9 +266,10 @@ export const ScanResultCard = (props: ScanResultCardProps) => {
{dateFormatter.format(scanResult.created_at)}
- {props.enabledFeatureFlags?.includes(
- "DataBrokerRemovalTimeEstimateLabel",
- ) &&
+ {props.isPremiumUser &&
+ props.enabledFeatureFlags?.includes(
+ "DataBrokerRemovalTimeEstimateLabel",
+ ) &&
props.experimentData?.["data-broker-removal-time-estimates"]
.enabled && (
<>
diff --git a/src/app/components/client/exposure_card/SubscriberBreachCard.tsx b/src/app/components/client/exposure_card/SubscriberBreachCard.tsx
index 1ace18d83e..8778a3a1f5 100644
--- a/src/app/components/client/exposure_card/SubscriberBreachCard.tsx
+++ b/src/app/components/client/exposure_card/SubscriberBreachCard.tsx
@@ -34,6 +34,7 @@ export type SubscriberBreachCardProps = {
locale: string;
resolutionCta: ReactNode;
isEligibleForPremium: boolean;
+ isPremiumUser: boolean;
isExpanded: boolean;
enabledFeatureFlags: FeatureFlagName[];
experimentData: ExperimentData;
@@ -211,9 +212,10 @@ export const SubscriberBreachCard = (props: SubscriberBreachCardProps) => {
{dateFormatter.format(subscriberBreach.addedDate)}
- {props.enabledFeatureFlags.includes(
- "DataBrokerRemovalTimeEstimateLabel",
- ) &&
+ {props.isPremiumUser &&
+ props.enabledFeatureFlags.includes(
+ "DataBrokerRemovalTimeEstimateLabel",
+ ) &&
props.experimentData["data-broker-removal-time-estimates"]
.enabled && (
<>
diff --git a/src/app/functions/server/glean.ts b/src/app/functions/server/glean.ts
index 44521ccf7d..8bfba10f8e 100644
--- a/src/app/functions/server/glean.ts
+++ b/src/app/functions/server/glean.ts
@@ -8,12 +8,13 @@ import { v4 as uuidv4 } from "uuid";
const GLEAN_EVENT_MOZLOG_TYPE = "glean-server-event";
-const loggingWinston = new LoggingWinston({
- labels: {
- name: GLEAN_EVENT_MOZLOG_TYPE,
- version: "0.1.0",
- },
-});
+const getLoggingWinston = () =>
+ new LoggingWinston({
+ labels: {
+ name: GLEAN_EVENT_MOZLOG_TYPE,
+ version: "0.1.0",
+ },
+ });
export function record(
category: string,
@@ -26,7 +27,7 @@ export function record(
// In GCP environments, use cloud logging instead of stdout.
// FIXME https://mozilla-hub.atlassian.net/browse/MNTOR-2401 - enable for stage and production
transports: ["gcpdev"].includes(process.env.APP_ENV ?? "local")
- ? [loggingWinston]
+ ? [getLoggingWinston()]
: [new transports.Console()],
});
diff --git a/src/app/functions/server/logging.ts b/src/app/functions/server/logging.ts
index e3f544454e..b5e6bc4952 100644
--- a/src/app/functions/server/logging.ts
+++ b/src/app/functions/server/logging.ts
@@ -5,12 +5,15 @@
import { createLogger, transports } from "winston";
import { LoggingWinston } from "@google-cloud/logging-winston";
-const loggingWinston = new LoggingWinston({
- labels: {
- name: "monitor-stats",
- version: "0.1.0",
- },
-});
+// Explicitly not run in tests (and other non-gcpdev environments)
+/* c8 ignore next 7 */
+const getLoggingWinston = () =>
+ new LoggingWinston({
+ labels: {
+ name: "monitor-stats",
+ version: "0.1.0",
+ },
+ });
export const logger = createLogger({
level: "info",
@@ -18,6 +21,6 @@ export const logger = createLogger({
// FIXME https://mozilla-hub.atlassian.net/browse/MNTOR-2401 - enable for stage and production
/* c8 ignore next 3 - cannot test this outside of GCP currently */
transports: ["gcpdev"].includes(process.env.APP_ENV ?? "local")
- ? [loggingWinston]
+ ? [getLoggingWinston()]
: [new transports.Console()],
});
diff --git a/src/emails/components/EmailDataPointCount.tsx b/src/emails/components/EmailDataPointCount.tsx
index 3a927ebfa0..eee9abf9a6 100644
--- a/src/emails/components/EmailDataPointCount.tsx
+++ b/src/emails/components/EmailDataPointCount.tsx
@@ -10,6 +10,7 @@ import { DashboardSummary } from "../../app/functions/server/dashboard";
import { getSignupLocaleCountry } from "../functions/getSignupLocaleCountry";
import { isEligibleForPremium } from "../../app/functions/universal/premium";
import { SanitizedSubscriberRow } from "../../app/functions/server/sanitize";
+import { sumSanitizedDataPoints } from "../functions/reduceSanitizedDataPoints";
type Props = {
l10n: ExtendedReactLocalization;
@@ -24,14 +25,6 @@ export const DataPointCount = (props: Props) => {
const assumedCountryCode = getSignupLocaleCountry(props.subscriber);
const unresolvedDataBreaches = props.dataSummary.dataBreachUnresolvedNum;
- const sumOfUnresolvedDataPoints =
- props.dataSummary.unresolvedSanitizedDataPoints.reduce(
- (total, dataPointSummary) => {
- return total + Object.values(dataPointSummary)[0];
- },
- 0,
- );
-
const hasRunFreeScan = typeof props.subscriber.onerep_profile_id === "number";
const utmContentSuffix = isEligibleForPremium(assumedCountryCode)
? "-us"
@@ -73,7 +66,9 @@ export const DataPointCount = (props: Props) => {
line-height="68px"
>
{hasRunFreeScan
- ? sumOfUnresolvedDataPoints
+ ? sumSanitizedDataPoints(
+ props.dataSummary.unresolvedSanitizedDataPoints,
+ )
: unresolvedDataBreaches}
{
: "email-monthly-report-no-scan-results-data-points-label",
{
data_point_count: hasRunFreeScan
- ? sumOfUnresolvedDataPoints
+ ? sumSanitizedDataPoints(
+ props.dataSummary.unresolvedSanitizedDataPoints,
+ )
: unresolvedDataBreaches,
},
)}
diff --git a/src/emails/functions/reduceSanitizedDataPoints.ts b/src/emails/functions/reduceSanitizedDataPoints.ts
new file mode 100644
index 0000000000..3d66af67a2
--- /dev/null
+++ b/src/emails/functions/reduceSanitizedDataPoints.ts
@@ -0,0 +1,13 @@
+/* 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/. */
+
+// data is inteferred from the SanitizedDataPoints type, but we ran into inference issues directly importing it
+export function sumSanitizedDataPoints(
+ data: Array>,
+): number {
+ return data.reduce((accumulatedValue, currentDataPoint) => {
+ const dataPointValue = Object.values(currentDataPoint)[0];
+ return accumulatedValue + dataPointValue;
+ }, 0);
+}
diff --git a/src/emails/templates/EmailFooter.tsx b/src/emails/templates/EmailFooter.tsx
index 15a1057d88..05dfbd30bb 100644
--- a/src/emails/templates/EmailFooter.tsx
+++ b/src/emails/templates/EmailFooter.tsx
@@ -31,12 +31,7 @@ export const EmailFooter = (props: Props) => {
height="36px"
align="center"
/>
-
+
{l10n.getString("email-footer-support-heading")}
@@ -76,12 +71,7 @@ export const EmailFooter = (props: Props) => {
},
)}
-
+
{l10n.getFragment("email-footer-source-hibp", {
elems: {
"hibp-link": (
@@ -100,20 +90,10 @@ export const EmailFooter = (props: Props) => {
width="150px"
align="center"
/>
-
+
149 New Montgomery St, 4th Floor, San Francisco, CA 94105
-
+
{l10n.getString("terms-of-service")}
@@ -151,12 +131,7 @@ export const RedesignedEmailFooter = (props: Props) => {
height="36px"
align="center"
/>
-
+
{l10n.getString("email-footer-support-heading")}
@@ -183,7 +158,6 @@ export const RedesignedEmailFooter = (props: Props) => {
align="center"
/>
{
San Francisco, CA 94105
{
return (
-
-
+
+
);
};
@@ -15,31 +15,22 @@ export const MetaTags = () => {
export const HeaderStyles = () => {
const hideBgImageOnDarkMode = `
:root {
- color-scheme: light dark;
- supported-color-schemes: light dark;
+ color-scheme: light only;
+ supported-color-schemes: light only;
}
- @media (prefers-color-scheme: light) {
- .footer_background {
- background-image: url(${process.env.SERVER_URL}/images/email/footer-bg-shapes.png);
- background-position: center bottom;
- background-repeat: no-repeat;
- }
+ .footer_background {
+ background-image: url(${process.env.SERVER_URL}/images/email/footer-bg-shapes.png);
+ background-position: center bottom;
+ background-repeat: no-repeat;
+ color: #000000 !important;
}
- @media (prefers-color-scheme: light) {
- .hero_background {
- background-image: url(${process.env.SERVER_URL}/images/email/hero-bg-gradient.png);
- background-repeat: repeat;
- background-color: #e4d2ff;
- background-position-x: 0;
- }
- }
-
- @media (prefers-color-scheme: dark) {
- .hero_background {
- background: none !important;
- }
+ .hero_background {
+ background-image: url(${process.env.SERVER_URL}/images/email/hero-bg-gradient.png);
+ background-repeat: repeat;
+ background-position-x: 0;
+ color: #000000 !important;
}
`;
diff --git a/src/emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail.tsx b/src/emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail.tsx
index 5673b1c3b4..1199629f53 100644
--- a/src/emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail.tsx
+++ b/src/emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail.tsx
@@ -13,6 +13,7 @@ import { isEligibleForPremium } from "../../../app/functions/universal/premium";
import { getSignupLocaleCountry } from "../../functions/getSignupLocaleCountry";
import { HeaderStyles, MetaTags } from "../HeaderStyles";
import { SanitizedSubscriberRow } from "../../../app/functions/server/sanitize";
+import { sumSanitizedDataPoints } from "../../functions/reduceSanitizedDataPoints";
export type MonthlyActivityFreeEmailProps = {
l10n: ExtendedReactLocalization;
@@ -78,19 +79,15 @@ export const MonthlyActivityFreeEmail = (
// Show a sum of resolved data breach & broker exposures if a scan has been run
// Otherwise, only show resolved data breaches
dataPointValue: hasRunFreeScan
- ? props.dataSummary.fixedSanitizedDataPoints.reduce(
- (total, dataPointSummary) => {
- return total + Object.values(dataPointSummary)[0];
- },
- 0,
- )
+ ? sumSanitizedDataPoints(props.dataSummary.fixedSanitizedDataPoints)
: props.dataSummary.dataBreachResolvedNum,
// The resolved box would be active if
// a user has run a free scan and they have resolved data breaches, and or brokers (count number of resolved data points)
// if a user hasn't run a free scan but they have resolved data breaches (count number of resolved breach cards)
activeState:
(hasRunFreeScan &&
- props.dataSummary.fixedSanitizedDataPoints.length > 0) ||
+ sumSanitizedDataPoints(props.dataSummary.fixedSanitizedDataPoints) >
+ 0) ||
(!hasRunFreeScan && props.dataSummary.dataBreachResolvedNum > 0),
};
@@ -101,7 +98,9 @@ export const MonthlyActivityFreeEmail = (
preScan: !hasRunFreeScan && props.dataSummary.dataBreachUnresolvedNum === 0,
postScan:
hasRunFreeScan &&
- props.dataSummary.unresolvedSanitizedDataPoints.length === 0,
+ sumSanitizedDataPoints(
+ props.dataSummary.unresolvedSanitizedDataPoints,
+ ) === 0,
};
return (
diff --git a/src/scripts/cronjobs/emailBreachAlerts.tsx b/src/scripts/cronjobs/emailBreachAlerts.tsx
index 7f7c7b6233..15b239a504 100644
--- a/src/scripts/cronjobs/emailBreachAlerts.tsx
+++ b/src/scripts/cronjobs/emailBreachAlerts.tsx
@@ -22,7 +22,7 @@ import {
addEmailNotification,
markEmailAsNotified,
} from "../../db/tables/email_notifications";
-import { initEmail, sendEmail } from "../../utils/email";
+import { initEmail, sendEmail, closeEmailPool } from "../../utils/email";
import {
getAddressesAndLanguageForEmail,
@@ -427,6 +427,7 @@ if (process.env.NODE_ENV !== "test") {
await knexSubscribers.destroy();
await knexEmailAddresses.destroy();
await knexHibp.destroy();
+ closeEmailPool();
Sentry.captureCheckIn({
checkInId,
monitorSlug: SENTRY_SLUG,
diff --git a/src/scripts/cronjobs/firstDataBrokerRemovalFixed.tsx b/src/scripts/cronjobs/firstDataBrokerRemovalFixed.tsx
index 96fbf30ca2..2971b13ce6 100644
--- a/src/scripts/cronjobs/firstDataBrokerRemovalFixed.tsx
+++ b/src/scripts/cronjobs/firstDataBrokerRemovalFixed.tsx
@@ -7,7 +7,7 @@ import {
getPotentialSubscribersWaitingForFirstDataBrokerRemovalFixedEmail,
markFirstDataBrokerRemovalFixedEmailAsJustSent,
} from "../../db/tables/subscribers";
-import { initEmail, sendEmail } from "../../utils/email";
+import { initEmail, sendEmail, closeEmailPool } from "../../utils/email";
import { renderEmail } from "../../emails/renderEmail";
import { FirstDataBrokerRemovalFixed } from "../../emails/templates/firstDataBrokerRemovalFixed/FirstDataBrokerRemovalFixed";
import { getCronjobL10n } from "../../app/functions/l10n/cronjobs";
@@ -103,6 +103,9 @@ async function run() {
);
}),
);
+
+ closeEmailPool();
+
console.log(
`[${new Date(Date.now()).toISOString()}] Sent [${subscribersToEmailWithData.length}] first data broker removal fixed emails.`,
);
diff --git a/src/scripts/cronjobs/monthlyActivityFree.tsx b/src/scripts/cronjobs/monthlyActivityFree.tsx
index 64ab8a6f80..0ce196c83a 100644
--- a/src/scripts/cronjobs/monthlyActivityFree.tsx
+++ b/src/scripts/cronjobs/monthlyActivityFree.tsx
@@ -4,7 +4,7 @@
import { SubscriberRow } from "knex/types/tables";
import { getFreeSubscribersWaitingForMonthlyEmail } from "../../db/tables/subscribers";
-import { initEmail, sendEmail } from "../../utils/email";
+import { initEmail, sendEmail, closeEmailPool } from "../../utils/email";
import { renderEmail } from "../../emails/renderEmail";
import { MonthlyActivityFreeEmail } from "../../emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail";
import { getCronjobL10n } from "../../app/functions/l10n/cronjobs";
@@ -43,6 +43,8 @@ async function run() {
sendMonthlyActivityEmail(subscriber),
),
);
+
+ closeEmailPool();
console.log(
`[${new Date(Date.now()).toISOString()}] Sent [${subscribersToEmail.length}] monthly activity emails to free users.`,
);
diff --git a/src/scripts/cronjobs/monthlyActivityPlus.tsx b/src/scripts/cronjobs/monthlyActivityPlus.tsx
index 25d55a91c4..f05c4ef6bf 100644
--- a/src/scripts/cronjobs/monthlyActivityPlus.tsx
+++ b/src/scripts/cronjobs/monthlyActivityPlus.tsx
@@ -7,7 +7,7 @@ import {
getPlusSubscribersWaitingForMonthlyEmail,
markMonthlyActivityPlusEmailAsJustSent,
} from "../../db/tables/subscribers";
-import { initEmail, sendEmail } from "../../utils/email";
+import { initEmail, sendEmail, closeEmailPool } from "../../utils/email";
import { renderEmail } from "../../emails/renderEmail";
import { MonthlyActivityPlusEmail } from "../../emails/templates/monthlyActivityPlus/MonthlyActivityPlusEmail";
import { getCronjobL10n } from "../../app/functions/l10n/cronjobs";
@@ -40,6 +40,8 @@ async function run() {
return sendMonthlyActivityEmail(subscriber);
}),
);
+
+ closeEmailPool();
console.log(
`[${new Date(Date.now()).toISOString()}] Sent [${subscribersToEmail.length}] monthly activity emails to Plus users.`,
);
diff --git a/src/utils/email.test.ts b/src/utils/email.test.ts
index 9389e8ee66..e635834fd2 100644
--- a/src/utils/email.test.ts
+++ b/src/utils/email.test.ts
@@ -154,6 +154,16 @@ test("EmailUtils.init with empty host uses jsonTransport. logs messages", async
);
});
+test("EmailUtils.closeEmailPool before .init() fails", async () => {
+ expect.assertions(1);
+
+ const { closeEmailPool } = await import("./email");
+
+ const expectedError = "`closeEmailPool` called before `initEmail`";
+
+ expect(() => closeEmailPool()).toThrow(expectedError);
+});
+
test("randomToken returns a random token of 2xlength (because of hex)", () => {
const token = randomToken(32);
expect(token).toHaveLength(64);
diff --git a/src/utils/email.ts b/src/utils/email.ts
index 151ec12d8b..e3a099f19f 100644
--- a/src/utils/email.ts
+++ b/src/utils/email.ts
@@ -15,7 +15,7 @@ let gTransporter: Transporter;
const envVars = getEnvVarsOrThrow(["SMTP_URL", "EMAIL_FROM", "SES_CONFIG_SET"]);
-async function initEmail(smtpUrl = envVars.SMTP_URL) {
+export async function initEmail(smtpUrl = envVars.SMTP_URL) {
// Allow a debug mode that will log JSON instead of sending emails.
if (!smtpUrl) {
logger.info("smtpUrl-empty", {
@@ -30,6 +30,17 @@ async function initEmail(smtpUrl = envVars.SMTP_URL) {
return gTransporterVerification;
}
+/** See https://nodemailer.com/smtp/pooled/ */
+export function closeEmailPool() {
+ if (!gTransporter) {
+ throw new Error("`closeEmailPool` called before `initEmail`");
+ /* c8 ignore next 5 */
+ }
+ // Not covered by tests because it involves a lot of mocks to basically check
+ // that we called this function. See test-coverage.md#mock-heavy
+ gTransporter.close();
+}
+
/**
* Send Email
*
@@ -37,7 +48,7 @@ async function initEmail(smtpUrl = envVars.SMTP_URL) {
* @param subject
* @param html
*/
-async function sendEmail(
+export async function sendEmail(
recipient: string,
subject: string,
html: string,
@@ -86,8 +97,6 @@ async function sendEmail(
}
}
-function randomToken(length: number = 64) {
+export function randomToken(length: number = 64) {
return crypto.randomBytes(length).toString("hex");
}
-
-export { initEmail, sendEmail, randomToken };
diff --git a/src/utils/fxa.ts b/src/utils/fxa.ts
index f55d041e9d..a7ea81b1a8 100644
--- a/src/utils/fxa.ts
+++ b/src/utils/fxa.ts
@@ -349,6 +349,61 @@ async function applyCoupon(
}
/* c8 ignore stop */
+/**
+ * @see https://mozilla.github.io/ecosystem-platform/api#tag/Devices-and-Sessions/operation/getAccountAttached_clients
+ */
+export type FxaGetAccountAttachedClients = {
+ clientId: string;
+ deviceId: number;
+ sessionTokenId: string;
+ refreshTokenId: string;
+ isCurrentSession: boolean;
+ deviceType: string;
+ name: string;
+ createdTime: string;
+ lastAccessTime: string;
+ scope: string[];
+ userAgent: string;
+ createdTimeFormatted?: string;
+ approximateLastAccessTime?: number;
+ location?: {
+ city: string;
+ country: string;
+ state: string;
+ stateCode: string;
+ };
+ os?: string;
+};
+
+// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
+/* c8 ignore start */
+async function getAttachedClients(
+ bearerToken: string,
+): Promise {
+ const endpointUrl = `${envVars.OAUTH_ACCOUNT_URI}/account/attached_clients`;
+ try {
+ const response = await fetch(endpointUrl, {
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${bearerToken}`,
+ },
+ });
+ const responseJson = await response.json();
+ if (!response.ok) throw new Error(JSON.stringify(responseJson));
+ logger.info("get_fxa_attached_clients_success");
+ return responseJson as FxaGetAccountAttachedClients[];
+ } catch (e) {
+ if (e instanceof Error) {
+ logger.error("get_fxa_attached_clients", {
+ stack: e.stack,
+ message: e.message,
+ });
+ }
+ throw e;
+ }
+}
+/* c8 ignore stop */
+
// TODO: Add unit test when changing this code:
/* c8 ignore next 3 */
function getSha1(email: crypto.BinaryLike) {
@@ -364,4 +419,5 @@ export {
getBillingAndSubscriptions,
deleteSubscription,
applyCoupon,
+ getAttachedClients,
};