Add cronjob for monthly free user activity email

This commit is contained in:
Vincent 2024-10-09 10:38:18 +02:00 коммит произвёл Vincent
Родитель ba0ec8193a
Коммит 453048d02f
5 изменённых файлов: 201 добавлений и 6 удалений

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

@ -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",

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

@ -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<SubscriberRow[]>("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,

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

@ -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";

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

@ -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(
<MonthlyActivityFreeEmail
subscriber={sanitizedSubscriber}
dataSummary={data}
l10n={l10n}
unsubscribeLink={unsubscribeLink}
/>,
),
);
}

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

@ -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,