Remove scripts about unconfirmed emails removal (#29683)

* Remove unconfirmed email deletion scripts

* Remove tests

GitOrigin-RevId: a0ef84207fced135a13074265fe5d3b38400d76f
This commit is contained in:
Antoine Clausse
2025-11-18 09:05:29 +00:00
committed by Copybot
parent 9408d253da
commit 1447842fbd
5 changed files with 0 additions and 1332 deletions
@@ -1,214 +0,0 @@
// @ts-check
import { db, ObjectId } from '../app/src/infrastructure/mongodb.js'
import fs from 'node:fs/promises'
import * as csv from 'csv'
import { promisify } from 'node:util'
import { scriptRunner } from './lib/ScriptRunner.mjs'
import { READ_PREFERENCE_SECONDARY } from '@overleaf/mongo-utils/batchedUpdate.js'
const CSV_FILENAME = '/tmp/unconfirmed_emails.csv'
/**
* @type {(csvString: string) => Promise<string[][]>}
*/
const parseAsync = promisify(csv.parse)
/**
* Checks the fallout of services/web/scripts/remove_unconfirmed_emails.mjs
* which wrongly removed some emails that have been confirmed by users
*/
async function main(trackProgress) {
console.time('check_removed_emails')
const csvContent = await fs.readFile(CSV_FILENAME, 'utf8')
const rows = await parseAsync(csvContent)
rows.shift() // Remove header row
const emailsByUserId = {}
for (const [userId, email] of rows) {
if (!emailsByUserId[userId]) {
emailsByUserId[userId] = []
}
emailsByUserId[userId].push(email)
}
const userIds = Object.keys(emailsByUserId)
let processedUsersCount = 0
const counts = {
/** @type {string[]} */
userNotFound: [],
/** @type {string[]} */
notDeleted: [],
deleted: 0,
/** @type {string[]} */
wasConfirmed: [],
/** @type {string[]} */
wasConfirmedLegacy: [],
/** @type {string[]} */
madePrimary: [],
/** @type {string[]} */
madeSecondary: [],
/** @type {string[]} */
isPrimary: [],
/** @type {string[]} */
isAddedAgain: [],
}
console.log('Total emails in the CSV:', rows.length)
console.log('Total users in the CSV:', userIds.length)
for (const userId of userIds) {
const userEmails = emailsByUserId[userId]
const user = await db.users.findOne(
{ _id: new ObjectId(userId) },
{ readPreference: READ_PREFERENCE_SECONDARY }
)
if (!user) {
counts.userNotFound.push(userId)
continue
}
for (const email of userEmails) {
const deletionLog = await db.userAuditLogEntries.findOne(
{
userId: new ObjectId(userId),
operation: 'remove-email',
'info.removedEmail': email,
'info.note': 'remove unconfirmed secondary emails',
},
{ readPreference: READ_PREFERENCE_SECONDARY }
)
if (!deletionLog) {
counts.notDeleted.push(email)
continue
}
counts.deleted++
if (user.email === email) {
counts.isPrimary.push(email)
}
const confirmationLog = await db.userAuditLogEntries.findOne(
{
userId: new ObjectId(userId),
operation: 'confirm-email-via-code',
'info.email': email,
timestamp: { $gt: new Date('2025-02-25') },
},
{ readPreference: READ_PREFERENCE_SECONDARY }
)
if (confirmationLog) {
counts.wasConfirmed.push(email)
}
const confirmationLegacyLog = await db.userAuditLogEntries.findOne(
{
userId: new ObjectId(userId),
operation: 'confirm-email',
'info.email': email,
timestamp: { $gt: new Date('2025-02-25') },
},
{ readPreference: READ_PREFERENCE_SECONDARY }
)
if (confirmationLegacyLog) {
counts.wasConfirmedLegacy.push(email)
}
const madePrimaryLog = await db.userAuditLogEntries.findOne(
{
userId: new ObjectId(userId),
operation: 'change-primary-email',
'info.newPrimaryEmail': email,
timestamp: { $gt: new Date('2025-02-25') },
},
{ readPreference: READ_PREFERENCE_SECONDARY }
)
if (madePrimaryLog) {
counts.madePrimary.push(email)
}
const madeSecondaryLog = await db.userAuditLogEntries.findOne(
{
userId: new ObjectId(userId),
operation: 'change-primary-email',
'info.oldPrimaryEmail': email,
timestamp: { $gt: new Date('2025-02-25') },
},
{ readPreference: READ_PREFERENCE_SECONDARY }
)
if (madeSecondaryLog) {
counts.madeSecondary.push(email)
}
if (user.emails.some(item => item.email === email)) {
counts.isAddedAgain.push(email)
}
}
processedUsersCount++
if (processedUsersCount % 100 === 0) {
trackProgress(`Processed ${processedUsersCount} users`)
}
}
console.log()
console.log('Total emails in the CSV:', rows.length)
console.log('Total users in the CSV:', userIds.length)
console.log('Total users processed:', processedUsersCount)
console.log()
console.log('Users not found:', JSON.stringify(counts.userNotFound))
console.log()
console.log('Emails not deleted:', counts.notDeleted.length)
console.log('Emails deleted:', counts.deleted)
console.log()
console.log('Emails that were confirmed:', counts.wasConfirmed.length)
console.log(
'Emails that were confirmed:',
JSON.stringify(counts.wasConfirmed)
)
console.log()
console.log(
'Emails that were confirmed (legacy):',
counts.wasConfirmedLegacy.length
)
console.log(
'Emails that were confirmed (legacy):',
JSON.stringify(counts.wasConfirmedLegacy)
)
console.log()
console.log('Emails that are primary:', counts.isPrimary.length)
console.log('Emails that are primary:', JSON.stringify(counts.isPrimary))
console.log()
console.log('Emails that were made primary:', counts.madePrimary.length)
console.log(
'Emails that were made primary:',
JSON.stringify(counts.madePrimary)
)
console.log()
console.log('Emails that were made secondary:', counts.madeSecondary.length)
console.log(
'Emails that were made secondary:',
JSON.stringify(counts.madeSecondary)
)
console.log()
console.log('Emails that were added again:', counts.isAddedAgain.length)
console.log(
'Emails that were added again:',
JSON.stringify(counts.isAddedAgain)
)
console.log()
console.timeEnd('check_removed_emails')
console.log()
}
try {
await scriptRunner(main)
process.exit(0)
} catch (error) {
console.error(error)
process.exit(1)
}
@@ -1,288 +0,0 @@
// @ts-check
import minimist from 'minimist'
import { db, ObjectId } from '../app/src/infrastructure/mongodb.js'
import fs from 'node:fs/promises'
import * as csv from 'csv'
import { promisify } from 'node:util'
import { scriptRunner } from './lib/ScriptRunner.mjs'
import Errors from '../app/src/Features/Errors/Errors.js'
import UserGetter from '../app/src/Features/User/UserGetter.mjs'
import { READ_PREFERENCE_SECONDARY } from '@overleaf/mongo-utils/batchedUpdate.js'
import UserUpdater from '../app/src/Features/User/UserUpdater.mjs'
import EmailHelper from '../app/src/Features/Helpers/EmailHelper.mjs'
import AsyncLocalStorage from '../app/src/infrastructure/AsyncLocalStorage.js'
import AnalyticsManager from '../app/src/Features/Analytics/AnalyticsManager.mjs'
import UserAuditLogHandler from '../app/src/Features/User/UserAuditLogHandler.mjs'
import InstitutionsAPI from '../app/src/Features/Institutions/InstitutionsAPI.mjs'
import OError from '@overleaf/o-error'
import EmailChangeHelper from '../app/src/Features/Analytics/EmailChangeHelper.mjs'
import logger from '@overleaf/logger'
const CSV_FILENAME = '/tmp/re_add_deleted_emails.csv'
/**
* @type {(csvString: string) => Promise<string[][]>}
*/
const parseAsync = promisify(csv.parse)
function usage() {
console.log('Usage: node re_add_deleted_emails.mjs [options]')
console.log(
'fix wrongly removed emails by remove_unconfirmed_emails (see 2025-11-05 email removal)'
)
console.log(
'run this script with a CSV containing user IDs and emails to re-add save in ',
CSV_FILENAME
)
console.log('Options:')
console.log(' --commit apply the changes')
process.exit(0)
}
const { commit, help } = minimist(process.argv.slice(2), {
boolean: ['commit', 'help'],
alias: { help: 'h' },
default: { commit: false },
})
/**
* @param {string} email
*/
async function isEmailUsed(email) {
try {
await UserGetter.promises.ensureUniqueEmailAddress(email)
return false
} catch (err) {
if (err instanceof Errors.EmailExistsError) {
return true
}
throw err
}
}
async function consumeCsvFile(trackProgress) {
console.time('re_add_deleted_emails')
const csvContent = await fs.readFile(CSV_FILENAME, 'utf8')
const rows = await parseAsync(csvContent)
rows.shift() // Remove header row
const emailsByUserId = {}
for (const [userId, email] of rows) {
if (!EmailHelper.parseEmail(email)) {
throw new Error(`invalid email ${email}`)
}
if (!emailsByUserId[userId]) {
emailsByUserId[userId] = []
}
emailsByUserId[userId].push(email)
}
const userIds = Object.keys(emailsByUserId)
const counts = {
/** @type {string[]} */
processedUsers: [],
/** @type {string[]} */
userNotFound: [],
/** @type {string[]} */
emailsInUse: [],
/** @type {string[]} */
alreadyOk: [],
/** @type {string[]} */
primary: [],
/** @type {string[]} */
secondary: [],
/** @type {string[]} */
addedEmails: [],
}
console.log('Total emails in the CSV:', rows.length)
console.log('Total users in the CSV:', userIds.length)
for (const userId of userIds) {
const candidateEmails = emailsByUserId[userId]
const user = await db.users.findOne(
{ _id: new ObjectId(userId) },
{ readPreference: READ_PREFERENCE_SECONDARY }
)
if (!user) {
counts.userNotFound.push(userId)
continue
}
for (const email of candidateEmails) {
if (user.emails.some(item => item.email === email)) {
counts.alreadyOk.push(email)
continue
}
const isUsed = await isEmailUsed(email)
const isOwnPrimary = user.email === email
if (isUsed && !isOwnPrimary) {
counts.emailsInUse.push(email)
continue
}
if (user.email === email) counts.primary.push(email)
else counts.secondary.push(email)
if (commit) {
const auditLog = {
initiatorId: null,
ipAddress: null,
info: {
script: true,
note: 'fix wrongly removed unconfirmed secondary email',
},
}
if (isOwnPrimary) {
// can't use addEmailAddress for primary email because ensureUniqueEmailAddress will throw
// using an override instead
await addEmailAddressOverride(user._id, email, {}, auditLog)
} else {
await UserUpdater.promises.addEmailAddress(
user._id,
email,
{},
auditLog
)
}
await UserUpdater.promises.confirmEmail(user._id, email)
}
counts.addedEmails.push(email)
}
counts.processedUsers.push(userId)
trackProgress(
`Processed users: ${counts.processedUsers.length}/${userIds.length}`
)
}
console.log()
if (!commit) {
console.log('Dry-run, use --commit to apply changes')
console.log('This would be the result:')
console.log()
}
console.log('Total emails in the CSV:', rows.length)
console.log('Total users in the CSV:', userIds.length)
console.log()
console.log('Users not found:', counts.userNotFound.length)
console.log('Users not found:', JSON.stringify(counts.userNotFound))
console.log()
console.log('Already OK:', counts.alreadyOk.length)
console.log('Already OK:', JSON.stringify(counts.alreadyOk))
console.log()
console.log('Already in use:', counts.emailsInUse.length)
console.log('Already in use:', JSON.stringify(counts.emailsInUse))
console.log()
console.log('Primary:', counts.primary.length)
console.log('Primary:', JSON.stringify(counts.primary))
console.log()
console.log('Secondary:', counts.secondary.length)
console.log('Secondary:', JSON.stringify(counts.secondary))
console.log()
console.log('Added emails:', counts.addedEmails.length)
console.log('Added emails:', JSON.stringify(counts.addedEmails))
console.log()
console.log()
console.timeEnd('re_add_deleted_emails')
console.log()
}
async function main(trackProgress) {
if (help) {
return usage()
}
await consumeCsvFile(trackProgress)
}
try {
await scriptRunner(main)
process.exit(0)
} catch (error) {
console.error(error)
process.exit(1)
}
async function addEmailAddressOverride(
userId,
newEmail,
affiliationOptions,
auditLog
) {
AsyncLocalStorage.removeItem('userFullEmails')
newEmail = EmailHelper.parseEmail(newEmail)
if (!newEmail) {
throw new Error('invalid email')
}
// Bypass ensureUniqueEmailAddress when re-adding primary emails
// await UserGetter.promises.ensureUniqueEmailAddress(newEmail)
AnalyticsManager.recordEventForUserInBackground(
userId,
'secondary-email-added'
)
await UserAuditLogHandler.promises.addEntry(
userId,
'add-email',
auditLog.initiatorId,
auditLog.ipAddress,
{
...auditLog.info,
newSecondaryEmail: newEmail,
}
)
try {
await InstitutionsAPI.promises.addAffiliation(
userId,
newEmail,
affiliationOptions
)
} catch (error) {
throw OError.tag(error, 'problem adding affiliation while adding email')
}
const createdAt = new Date()
let res
try {
const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
const update = {
$push: {
emails: { email: newEmail, createdAt, reversedHostname },
},
}
res = await UserUpdater.promises.updateUser(
{ _id: userId, 'emails.email': { $ne: newEmail } },
update
)
} catch (error) {
throw OError.tag(error, 'problem updating users emails')
}
if (res.matchedCount !== 1) {
return
}
try {
await EmailChangeHelper.registerEmailCreation(userId, newEmail, {
// @ts-expect-error - This is copied from UserUpdater.mjs
createdAt: new Date(),
emailCreatedAt: createdAt,
})
} catch (error) {
logger.warn(
{ error, userId, newEmail },
'Error registering email creation with analytics'
)
}
}
@@ -1,265 +0,0 @@
// @ts-check
import minimist from 'minimist'
import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
import { db, ObjectId } from '../app/src/infrastructure/mongodb.js'
import UserAuditLogHandler from '../app/src/Features/User/UserAuditLogHandler.mjs'
import fs from 'node:fs/promises'
import * as csv from 'csv'
import { promisify } from 'node:util'
import _ from 'lodash'
import { scriptRunner } from './lib/ScriptRunner.mjs'
const CSV_FILENAME = '/tmp/remove_unconfirmed_emails.csv'
/**
* @type {(records: string[][]) => Promise<string>}
*/
const stringifyAsync = promisify(csv.stringify)
/**
* @type {(csvString: string) => Promise<string[][]>}
*/
const parseAsync = promisify(csv.parse)
function usage() {
console.log('Usage: node remove_unconfirmed_emails.mjs')
console.log('Removes unconfirmed emails from users')
console.log('Options:')
console.log(
'' +
' --generate generate the CSV file (remove_unconfirmed_emails.csv) containing the emails to remove\n' +
' --consume consume the CSV file (remove_unconfirmed_emails.csv) and remove the emails (by default it is a dry-run)\n' +
' --commit apply the changes (to be used with --consume)\n'
)
process.exit(0)
}
const { generate, consume, commit, help } = minimist(process.argv.slice(2), {
boolean: ['generate', 'consume', 'commit', 'help'],
alias: { help: 'h' },
default: { generate: false, consume: false, commit: false },
})
async function generateCsvFile(trackProgress) {
console.time('generate_csv')
let processedUsersCount = 0
let skippedUnconfirmedPrimaries = 0
let totalEmailsToRemove = 0
let totalUsersInCsv = 0
const records = [['User ID', 'Email', 'Sign Up Date']]
await batchedUpdate(
db.users,
{
$and: [
{ emails: { $exists: true } },
{ emails: { $not: { $size: 0 } } },
// Warning: this also matches unconfirmed primary emails
{
emails: {
$elemMatch: {
$or: [{ confirmedAt: { $exists: false } }, { confirmedAt: null }],
},
},
},
],
},
async users => {
console.log('Process', users.length, 'users')
processedUsersCount += users.length
for (const user of users) {
const unconfirmedSecondaries = user.emails.filter(
email => !email.confirmedAt && email.email !== user.email
)
if (unconfirmedSecondaries.length === 0) {
// Users can have been selected because of their unconfirmed primary email
// we don't want to remove those
skippedUnconfirmedPrimaries++
continue
}
for (const email of unconfirmedSecondaries) {
records.push([
user._id.toString(),
email.email,
user.signUpDate.toISOString(),
])
}
totalUsersInCsv++
totalEmailsToRemove += unconfirmedSecondaries.length
}
},
{ _id: 1, signUpDate: 1, emails: 1, email: 1 },
undefined,
{ trackProgress }
)
const csvContent = await stringifyAsync(records)
await fs.writeFile(CSV_FILENAME, csvContent)
console.log()
console.log('Processed users:', processedUsersCount)
console.log()
console.log('Generated CSV file:', CSV_FILENAME)
console.log('Total emails in the CSV:', totalEmailsToRemove)
console.log('Total users in the CSV:', totalUsersInCsv)
console.log(
'Unconfirmed primary emails (skipped):',
skippedUnconfirmedPrimaries
)
console.log()
console.timeEnd('generate_csv')
console.log()
}
async function consumeCsvFile() {
console.time('consume_csv')
const csvContent = await fs.readFile(CSV_FILENAME, 'utf8')
const rows = await parseAsync(csvContent)
rows.shift() // Remove header row
const emailsByUserId = {}
for (const [userId, email] of rows) {
if (!emailsByUserId[userId]) {
emailsByUserId[userId] = []
}
emailsByUserId[userId].push(email)
}
const userIds = Object.keys(emailsByUserId)
let processedUsersCount = 0
let removedEmailsCount = 0
let totalModifiedUsersCount = 0
const skippedEmail = {
userNotFound: 0,
nowConfirmed: 0,
nowPrimary: 0,
nowRemoved: 0,
}
console.log('Total emails in the CSV:', rows.length)
console.log('Total users in the CSV:', userIds.length)
for (const userId of userIds) {
const emailsToRemoveCandidates = emailsByUserId[userId]
const user = await db.users.findOne({ _id: new ObjectId(userId) })
if (!user) {
skippedEmail.userNotFound += emailsToRemoveCandidates.length
continue
}
const emailsToRemove = emailsToRemoveCandidates.filter(email => {
const currentEmail = user.emails.find(e => e.email === email)
if (!currentEmail) {
skippedEmail.nowRemoved++
return false
}
if (currentEmail.confirmedAt) {
skippedEmail.nowConfirmed++
return false
}
if (currentEmail.email === user.email) {
skippedEmail.nowPrimary++
return false
}
return true
})
removedEmailsCount += emailsToRemove.length
if (commit && emailsToRemove.length > 0) {
for (const email of emailsToRemove) {
await UserAuditLogHandler.promises.addEntry(
userId,
'remove-email',
undefined,
undefined,
{
removedEmail: email,
script: true,
note: 'remove unconfirmed secondary emails',
}
)
}
const updated = await db.users.updateOne(
{ _id: new ObjectId(userId) },
{ $pull: { emails: { email: { $in: emailsToRemove } } } }
)
totalModifiedUsersCount += updated.modifiedCount
}
processedUsersCount++
if (processedUsersCount % 100 === 0) {
console.log('Processed', processedUsersCount, 'users')
}
}
console.log()
if (!commit) {
console.log('Dry-run, use --commit to apply changes')
console.log('This would be the result:')
console.log()
}
console.log('Total emails in the CSV:', rows.length)
console.log('Total users in the CSV:', userIds.length)
console.log('Total users processed:', processedUsersCount)
console.log('Total emails removed:', removedEmailsCount)
console.log('Skipped emails:', _.sum(Object.values(skippedEmail)))
console.log(' - User not found:', skippedEmail.userNotFound)
console.log(' - Email now confirmed:', skippedEmail.nowConfirmed)
console.log(' - Email now primary:', skippedEmail.nowPrimary)
console.log(' - Email now removed:', skippedEmail.nowRemoved)
console.log()
if (commit) {
console.log('Total users modified:', totalModifiedUsersCount)
} else {
console.log('Note: this was a dry-run. No changes were made.')
}
console.log()
console.timeEnd('consume_csv')
console.log()
}
async function main(trackProgress) {
if (help) {
return usage()
}
if (!generate && !consume) {
console.error('Error: Either --generate or --consume must be specified')
return usage()
}
if (generate && consume) {
console.error('Error: Cannot use both --generate and --consume together')
return usage()
}
if (commit && !consume) {
console.error('Error: --commit can only be used with --consume')
return usage()
}
if (generate) {
await generateCsvFile(trackProgress)
} else if (consume) {
await consumeCsvFile()
}
}
try {
await scriptRunner(main)
process.exit(0)
} catch (error) {
console.error(error)
process.exit(1)
}