From 453048d02f4cd14955b9eeff6208aa7f21dfe97f Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 9 Oct 2024 10:38:18 +0200 Subject: [PATCH] Add cronjob for monthly free user activity email --- package.json | 3 +- src/db/tables/subscribers.ts | 94 +++++++++++++++- .../MonthlyActivityFreeEmail.tsx | 2 +- src/scripts/cronjobs/monthlyActivityFree.tsx | 104 ++++++++++++++++++ src/scripts/cronjobs/monthlyActivityPlus.tsx | 4 +- 5 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 src/scripts/cronjobs/monthlyActivityFree.tsx diff --git a/package.json b/package.json index 23b60df26..c57483ad8 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "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-free": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/monthlyActivityFree.tsx", "dev:cron:monthly-activity-plus": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/monthlyActivityPlus.tsx", "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", @@ -27,7 +28,7 @@ "e2e:debug": "playwright test src/e2e/ --ui", "e2e:smoke": "playwright test src/e2e/ --grep @smoke", "cron:first-data-broker-removal-fixed": "node dist/scripts/cronjobs/firstDataBrokerRemovalFixed.js", - "cron:monthly-activity-free": "echo 'To be implemented; added so the cronjob can be added asynchronously.'", + "cron:monthly-activity-free": "node dist/scripts/cronjobs/monthlyActivityFree.js", "cron:monthly-activity-plus": "node dist/scripts/cronjobs/monthlyActivityPlus.js", "cron:breach-alerts": "node dist/scripts/cronjobs/emailBreachAlerts.js", "cron:db-delete-unverified-subscribers": "node dist/scripts/cronjobs/deleteUnverifiedSubscribers.js", diff --git a/src/db/tables/subscribers.ts b/src/db/tables/subscribers.ts index 1c29f5b83..7d42e6b71 100644 --- a/src/db/tables/subscribers.ts +++ b/src/db/tables/subscribers.ts @@ -8,6 +8,7 @@ import createDbConnection from "../connect"; import { SerializedSubscriber } from "../../next-auth.js"; import { getFeatureFlagData } from "./featureFlags"; import { getEnvVarsOrThrow } from "../../envVars"; +import { parseIso8601Datetime } from "../../utils/parse"; const knex = createDbConnection(); const { DELETE_UNVERIFIED_SUBSCRIBERS_TIMER } = getEnvVarsOrThrow([ @@ -413,6 +414,92 @@ async function getPlusSubscribersWaitingForMonthlyEmail( } /* c8 ignore stop */ +// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy +/* c8 ignore start */ +async function getFreeSubscribersWaitingForMonthlyEmail(): Promise< + SubscriberRow[] +> { + const flag = await getFeatureFlagData("MonthlyReportFreeUser"); + const accountCutOffDate = parseIso8601Datetime( + process.env.MONTHLY_ACTIVITY_FREE_EMAIL_ACCOUNT_CUTOFF_DATE ?? + "2022-01-01T00:00:00.000Z", + ); + + if (!flag?.is_enabled) { + return []; + } + + let query = knex("subscribers") + .select("subscribers.*") + .leftJoin( + "subscriber_email_preferences", + "subscribers.id", + "subscriber_email_preferences.subscriber_id", + ) + // Only send to users who haven't opted out of the monthly activity email... + // (Note that the `monthly_monitor_report` column is re-used for both the Plus + // user activity email, and the free user activity email.) + // It looks like Knex's `.where` type definition doesn't accept Promise-returning + // functions, even though the code does; hence the `eslint-disable`) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where((builder) => + builder + .whereNull("monthly_monitor_report_free") + .orWhere("monthly_monitor_report_free", true), + ) + // ...who haven't received the email in the last 1 month... + // It looks like Knex's `.where` type definition doesn't accept Promise-returning + // functions, even though the code does; hence the `eslint-disable`) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .andWhere((builder) => + builder + .whereNull("monthly_monitor_report_free_at") + .orWhereRaw( + "\"monthly_monitor_report_free_at\" < NOW() - INTERVAL '1 month'", + ), + ) + // ...whose account is older than 1 month... + .andWhereRaw("subscribers.\"created_at\" < NOW() - INTERVAL '1 month'") + // ...but is no older than three years... + .andWhere("subscribers.created_at", ">=", accountCutOffDate) + // ...and who do not have a Plus subscription. + // Note: This will only match people of whom the Monitor database knows that + // they have a Plus subscription. SubPlat is the source of truth, but + // our database is updated via a webhook and whenever the user logs + // in. Locally, you might want to set this via `/admin/dev/`. + // It looks like Knex's `.where` type definition doesn't accept Promise-returning + // functions, even though the code does; hence the `eslint-disable`) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .andWhere((builder) => + builder + .whereRaw( + `NOT (subscribers.fxa_profile_json)::jsonb \\? 'subscriptions'`, + ) + .orWhereRaw( + `NOT (subscribers.fxa_profile_json->'subscriptions')::jsonb \\? ?`, + MONITOR_PREMIUM_CAPABILITY, + ), + ); + + if (Array.isArray(flag.allow_list) && flag.allow_list.length > 0) { + // If the feature flag has an allowlist, only send to users on that list. + // The `.andWhereIn` alias doesn't exist: + // https://github.com/knex/knex/issues/1881#issuecomment-275433906 + query = query.whereIn("subscribers.primary_email", flag.allow_list); + } + // One thing to note as being absent from this query: a LIMIT clause. + // The reason for this is that we want to filter out people who had + // a language other than `en-US` set when signing up, but to do so, + // we need to parse the `accept-language` field, which we can only + // do after we have the query results. Thus, if we were to limit + // the result of this query, we would at some point end up filtering + // every returned row, and then never moving on to the rows after that. + const rows = await query; + + return rows; +} +/* c8 ignore stop */ + // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ async function markFirstDataBrokerRemovalFixedEmailAsJustSent( @@ -439,7 +526,9 @@ async function markFirstDataBrokerRemovalFixedEmailAsJustSent( /* c8 ignore stop */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function markMonthlyActivityEmailAsJustSent(subscriber: SubscriberRow) { +async function markMonthlyActivityPlusEmailAsJustSent( + subscriber: SubscriberRow, +) { const affectedSubscribers = await knex("subscribers") .update({ // @ts-ignore knex.fn.now() results in it being set to a date, @@ -567,9 +656,10 @@ export { setMonthlyMonitorReport, setBreachResolution, getPotentialSubscribersWaitingForFirstDataBrokerRemovalFixedEmail, + getFreeSubscribersWaitingForMonthlyEmail, getPlusSubscribersWaitingForMonthlyEmail, markFirstDataBrokerRemovalFixedEmailAsJustSent, - markMonthlyActivityEmailAsJustSent, + markMonthlyActivityPlusEmailAsJustSent, deleteUnverifiedSubscribers, deleteSubscriber, deleteResolutionsWithEmail, diff --git a/src/emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail.tsx b/src/emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail.tsx index 125e44dfc..dd71a91d8 100644 --- a/src/emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail.tsx +++ b/src/emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail.tsx @@ -2,7 +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 { ExtendedReactLocalization } from "../../../app/functions/l10n"; +import type { ExtendedReactLocalization } from "../../../app/functions/l10n"; import { EmailFooter } from "../EmailFooter"; import { EmailHero } from "../../components/EmailHero"; import { DataPointCount } from "../../components/EmailDataPointCount"; diff --git a/src/scripts/cronjobs/monthlyActivityFree.tsx b/src/scripts/cronjobs/monthlyActivityFree.tsx new file mode 100644 index 000000000..025d15efe --- /dev/null +++ b/src/scripts/cronjobs/monthlyActivityFree.tsx @@ -0,0 +1,104 @@ +/* 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 { SubscriberRow } from "knex/types/tables"; +import { getFreeSubscribersWaitingForMonthlyEmail } from "../../db/tables/subscribers"; +import { initEmail, sendEmail } from "../../utils/email"; +import { renderEmail } from "../../emails/renderEmail"; +import { MonthlyActivityFreeEmail } from "../../emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail"; +import { getCronjobL10n } from "../../app/functions/l10n/cronjobs"; +import { sanitizeSubscriberRow } from "../../app/functions/server/sanitize"; +import { getDashboardSummary } from "../../app/functions/server/dashboard"; +import { getLatestOnerepScanResults } from "../../db/tables/onerep_scans"; +import { getSubscriberBreaches } from "../../app/functions/server/getSubscriberBreaches"; +import { refreshStoredScanResults } from "../../app/functions/server/refreshStoredScanResults"; +import { getSignupLocaleCountry } from "../../emails/functions/getSignupLocaleCountry"; +import { updateEmailPreferenceForSubscriber } from "../../db/tables/subscriber_email_preferences"; +import { unsubscribeLinkForSubscriber } from "../../app/api/utils/email"; + +void run(); + +async function run() { + const batchSize = Number.parseInt( + process.env.MONTHLY_ACTIVITY_FREE_EMAIL_BATCH_SIZE ?? "10", + 10, + ); + if (Number.isNaN(batchSize)) { + throw new Error( + `Could not send monthly activity emails, because the env var MONTHLY_ACTIVITY_FREE_EMAIL_BATCH_SIZE has a non-numeric value: [${process.env.MONTHLY_ACTIVITY_EMAIL_BATCH_SIZE}].`, + ); + } + const subscribersToEmail = (await getFreeSubscribersWaitingForMonthlyEmail()) + .filter((subscriber) => { + const assumedCountryCode = getSignupLocaleCountry(subscriber); + return assumedCountryCode === "us"; + }) + .slice(0, batchSize); + await initEmail(); + + await Promise.allSettled( + subscribersToEmail.map((subscriber) => + sendMonthlyActivityEmail(subscriber), + ), + ); + console.log( + `[${new Date(Date.now()).toISOString()}] Sent [${subscribersToEmail.length}] monthly activity emails to free users.`, + ); +} + +async function sendMonthlyActivityEmail(subscriber: SubscriberRow) { + const sanitizedSubscriber = sanitizeSubscriberRow(subscriber); + const l10n = getCronjobL10n(sanitizedSubscriber); + /** + * Without an active user session, we don't know the user's country. This is + * our best guess based on their locale. At the time of writing, it's only + * used to determine whether to count SSN breaches (which we don't have + * recommendations for outside the US). + */ + const countryCodeGuess = getSignupLocaleCountry(subscriber); + + // OneRep suggested not relying on webhooks, but instead to fetch the latest + // data from their API. Thus, let's refresh the data in our DB in real-time: + if (subscriber.onerep_profile_id !== null) { + await refreshStoredScanResults(subscriber.onerep_profile_id); + } + + const latestScan = await getLatestOnerepScanResults( + subscriber.onerep_profile_id, + ); + const subscriberBreaches = await getSubscriberBreaches({ + fxaUid: subscriber.fxa_uid, + countryCode: countryCodeGuess, + }); + const data = getDashboardSummary(latestScan.results, subscriberBreaches); + + const subject = l10n.getString("email-monthly-free-subject"); + const unsubscribeLink = await unsubscribeLinkForSubscriber(subscriber); + + if (unsubscribeLink === null) { + throw new Error( + `Trying to send a monthly activity email to a free user, but the unsubscribe link could not be generated: [${unsubscribeLink}].`, + ); + } + + // Update the last-sent date *first*, so that if something goes wrong, we + // don't keep resending the email a brazillion times. + await updateEmailPreferenceForSubscriber(subscriber.id, true, { + monthly_monitor_report_free_at: new Date(Date.now()), + }); + + await sendEmail( + sanitizedSubscriber.primary_email, + subject, + renderEmail( + , + ), + ); +} diff --git a/src/scripts/cronjobs/monthlyActivityPlus.tsx b/src/scripts/cronjobs/monthlyActivityPlus.tsx index f2dd593b2..25d55a91c 100644 --- a/src/scripts/cronjobs/monthlyActivityPlus.tsx +++ b/src/scripts/cronjobs/monthlyActivityPlus.tsx @@ -5,7 +5,7 @@ import { SubscriberRow } from "knex/types/tables"; import { getPlusSubscribersWaitingForMonthlyEmail, - markMonthlyActivityEmailAsJustSent, + markMonthlyActivityPlusEmailAsJustSent, } from "../../db/tables/subscribers"; import { initEmail, sendEmail } from "../../utils/email"; import { renderEmail } from "../../emails/renderEmail"; @@ -75,7 +75,7 @@ async function sendMonthlyActivityEmail(subscriber: SubscriberRow) { // Update the last-sent date *first*, so that if something goes wrong, we // don't keep resending the email a brazillion times. - await markMonthlyActivityEmailAsJustSent(subscriber); + await markMonthlyActivityPlusEmailAsJustSent(subscriber); await sendEmail( sanitizedSubscriber.primary_email,