Add cronjob for monthly free user activity email
This commit is contained in:
Родитель
ba0ec8193a
Коммит
453048d02f
|
@ -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,
|
||||
|
|
Загрузка…
Ссылка в новой задаче