packages/fxa-auth-server/lib/subscription-account-reminders.js (178 lines of code) (raw):

/* 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 https://mozilla.org/MPL/2.0/. */ // This module implements logic for managing account verification reminders. // // Reminder records are stored in Redis sorted sets on account creation and // removed when an acount is verified. A separate script, running on the // fxa-admin box, processes reminder records in a cron job by pulling the // records that have ticked passed an expiry limit set in config and sending // the appropriate reminder email to the address associated with each account. // // Right now, config determines how many reminder emails are sent and what // the expiry intervals for them are. Ultimately though, that might be a good // candidate to control with feature flags. // // More detail on sorted sets: // // * https://redis.io/topics/data-types#sorted-sets // * https://redis.io/topics/data-types-intro#redis-sorted-sets const { props } = require('fxa-shared/lib/promise-extras'); const INTERVAL_PATTERN = /^([a-z]+)Interval$/; const METADATA_KEY_SUB_FLOW = 'metadata_sub_flow'; /** * Initialise the subscriptionAccount reminders module. * * @param {Object} log * @param {Object} config * @returns {SubscriptionAccountReminders} */ module.exports = (log, config) => { if (!config.redis || !config.redis.host) { return { keys: [], async create(uid, flowId, flowBeginTime, deviceId, productId, productName) { return {}; }, async delete(uid) { return {}; }, async process() { return {}; }, async reinstate(key, reminders) { return {}; }, async close() {}, }; } const redis = require('./redis')( { ...config.redis, ...config.subscriptionAccountReminders.redis, enabled: true, }, log ); const { rolloutRate } = config.subscriptionAccountReminders; const { keys, intervals } = Object.entries( config.subscriptionAccountReminders ).reduce( ({ keys, intervals }, [key, value]) => { const matches = INTERVAL_PATTERN.exec(key); if (matches && matches.length === 2) { const key = matches[1]; if (key === METADATA_KEY_SUB_FLOW) { throw new Error('Invalid subscriptionAccount reminder key found in config'); } keys.push(key); intervals[key] = value; } return { keys, intervals }; }, { keys: [], intervals: {} } ); /** * @typedef {Object} SubscriptionAccountReminders * @property {Array} keys * @property {Function} create * @property {Function} delete * @property {Function} process * * Each method below returns a promise that resolves to an object, * the shape of which is determined by config. If config has settings * for `firstInterval` and `secondInterval` (as at time of writing), * the shape of those objects would be `{ first, second }`. */ return { keys: keys.slice(), /** * Create subscriptionAccount reminder records for an account. * * @param {String} uid * @param {String} [flowId] * @param {String} [flowBeginTime] * @returns {Promise} - Each property on the resolved object will be the number * of elements added to that sorted set, i.e. the result of * [`redis.zadd`](https://redis.io/commands/zadd). */ async create(uid, flowId, flowBeginTime, deviceId, productId, productName,now = Date.now()) { try { if (rolloutRate <= 1 && Math.random() < rolloutRate) { const result = await props( keys.reduce((result, key) => { result[key] = redis.zadd(key, now, uid); return result; }, {}) ); if (flowId && flowBeginTime && deviceId && productId && productName) { await redis.set( `${METADATA_KEY_SUB_FLOW}:${uid}`, JSON.stringify([flowId, flowBeginTime, deviceId, productId, productName]) ); } log.info('subscriptionAccountReminders.create', { uid, flowId, flowBeginTime, }); return result; } } catch (err) { log.error('subscriptionAccountReminders.create.error', { err, uid, flowId, flowBeginTime, }); throw err; } }, /** * Delete subscriptionAccount reminder records for an account. * * @param {String} uid * @returns {Promise} - Each property on the resolved object will be the number of * elements removed from that sorted set, i.e. the result of * [`redis.zrem`](https://redis.io/commands/zrem). */ async delete(uid) { try { const result = await props( keys.reduce((result, key) => { result[key] = redis.zrem(key, uid); return result; }, {}) ); await redis.del(`${METADATA_KEY_SUB_FLOW}:${uid}`); log.info('subscriptionAccountReminders.delete', { uid }); return result; } catch (err) { log.error('subscriptionAccountReminders.delete.error', { err, uid }); throw err; } }, /** * Read and remove all subscriptionAccount reminders that have * ticked past the expiry intervals set in config. * * @returns {Promise} - Each property on the resolved object will be an array of * { timestamp, uid, flowId, flowBeginTime, deviceId, productId, productName } reminder records * that have ticked past the relevant expiry interval. */ async process(now = Date.now()) { try { return await props( keys.reduce((result, key, keyIndex) => { const cutoff = now - intervals[key]; result[key] = redis .zpoprangebyscore(key, 0, cutoff, 'WITHSCORES') .then(async (items) => { const reminders = []; let index = 0; for (const item of items) { if (index % 2 === 0) { const uid = item; let metadata = await redis.get(`${METADATA_KEY_SUB_FLOW}:${uid}`); if (metadata) { const [flowId, flowBeginTime, deviceId, productId, productName] = JSON.parse(metadata); metadata = { flowId, flowBeginTime, deviceId, productId, productName }; if (keyIndex === keys.length - 1) { await redis.del(`${METADATA_KEY_SUB_FLOW}:${uid}`); } } reminders.push({ uid, ...metadata }); } else { reminders[(index - 1) / 2].timestamp = item; } index++; } return reminders; }); log.info('subscriptionAccountReminders.process', { key, now, cutoff }); return result; }, {}) ); } catch (err) { log.error('subscriptionAccountReminders.process.error', { err }); throw err; } }, /** * Reinstate failed reminders using their original timestamps. * Each reminder item is an object of the form { timestamp, uid }. * * @param {String} key * @param {Array} reminders * @returns {Promise} - The number of reminders reinstated to the sorted set. */ async reinstate(key, reminders) { try { const metadata = []; const result = await redis.zadd( key, ...reminders.reduce((args, reminder) => { const { timestamp, uid, flowId, flowBeginTime, deviceId, productId, productName } = reminder; args.push(timestamp, uid); if (flowId && flowBeginTime && deviceId && productId && productName) { metadata.push({ uid, flowId, flowBeginTime, deviceId, productId, productName }); } return args; }, []) ); await Promise.all( metadata.map(({ uid, flowId, flowBeginTime, deviceId, productId, productName }) => { return redis.set( `${METADATA_KEY_SUB_FLOW}:${uid}`, JSON.stringify([flowId, flowBeginTime, deviceId, productId, productName]) ); }) ); log.info('subscriptionAccountReminders.reinstate', { key, reminders }); return result; } catch (err) { log.error('subscriptionAccountReminders.reinstate.error', { err }); throw err; } }, /** * Close the underlying redis connections. * * @returns {Promise} */ close() { return redis.close(); }, }; };