re_add_deleted_emails.mjs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. // @ts-check
  2. import minimist from 'minimist'
  3. import { db, ObjectId } from '../app/src/infrastructure/mongodb.js'
  4. import fs from 'node:fs/promises'
  5. import * as csv from 'csv'
  6. import { promisify } from 'node:util'
  7. import { scriptRunner } from './lib/ScriptRunner.mjs'
  8. import Errors from '../app/src/Features/Errors/Errors.js'
  9. import UserGetter from '../app/src/Features/User/UserGetter.mjs'
  10. import { READ_PREFERENCE_SECONDARY } from '@overleaf/mongo-utils/batchedUpdate.js'
  11. import UserUpdater from '../app/src/Features/User/UserUpdater.mjs'
  12. import EmailHelper from '../app/src/Features/Helpers/EmailHelper.js'
  13. import AsyncLocalStorage from '../app/src/infrastructure/AsyncLocalStorage.js'
  14. import AnalyticsManager from '../app/src/Features/Analytics/AnalyticsManager.mjs'
  15. import UserAuditLogHandler from '../app/src/Features/User/UserAuditLogHandler.mjs'
  16. import InstitutionsAPI from '../app/src/Features/Institutions/InstitutionsAPI.mjs'
  17. import OError from '@overleaf/o-error'
  18. import EmailChangeHelper from '../app/src/Features/Analytics/EmailChangeHelper.mjs'
  19. import logger from '@overleaf/logger'
  20. const CSV_FILENAME = '/tmp/re_add_deleted_emails.csv'
  21. /**
  22. * @type {(csvString: string) => Promise<string[][]>}
  23. */
  24. const parseAsync = promisify(csv.parse)
  25. function usage() {
  26. console.log('Usage: node re_add_deleted_emails.mjs [options]')
  27. console.log(
  28. 'fix wrongly removed emails by remove_unconfirmed_emails (see 2025-11-05 email removal)'
  29. )
  30. console.log(
  31. 'run this script with a CSV containing user IDs and emails to re-add save in ',
  32. CSV_FILENAME
  33. )
  34. console.log('Options:')
  35. console.log(' --commit apply the changes')
  36. process.exit(0)
  37. }
  38. const { commit, help } = minimist(process.argv.slice(2), {
  39. boolean: ['commit', 'help'],
  40. alias: { help: 'h' },
  41. default: { commit: false },
  42. })
  43. /**
  44. * @param {string} email
  45. */
  46. async function isEmailUsed(email) {
  47. try {
  48. await UserGetter.promises.ensureUniqueEmailAddress(email)
  49. return false
  50. } catch (err) {
  51. if (err instanceof Errors.EmailExistsError) {
  52. return true
  53. }
  54. throw err
  55. }
  56. }
  57. async function consumeCsvFile(trackProgress) {
  58. console.time('re_add_deleted_emails')
  59. const csvContent = await fs.readFile(CSV_FILENAME, 'utf8')
  60. const rows = await parseAsync(csvContent)
  61. rows.shift() // Remove header row
  62. const emailsByUserId = {}
  63. for (const [userId, email] of rows) {
  64. if (!EmailHelper.parseEmail(email)) {
  65. throw new Error(`invalid email ${email}`)
  66. }
  67. if (!emailsByUserId[userId]) {
  68. emailsByUserId[userId] = []
  69. }
  70. emailsByUserId[userId].push(email)
  71. }
  72. const userIds = Object.keys(emailsByUserId)
  73. const counts = {
  74. /** @type {string[]} */
  75. processedUsers: [],
  76. /** @type {string[]} */
  77. userNotFound: [],
  78. /** @type {string[]} */
  79. emailsInUse: [],
  80. /** @type {string[]} */
  81. alreadyOk: [],
  82. /** @type {string[]} */
  83. primary: [],
  84. /** @type {string[]} */
  85. secondary: [],
  86. /** @type {string[]} */
  87. addedEmails: [],
  88. }
  89. console.log('Total emails in the CSV:', rows.length)
  90. console.log('Total users in the CSV:', userIds.length)
  91. for (const userId of userIds) {
  92. const candidateEmails = emailsByUserId[userId]
  93. const user = await db.users.findOne(
  94. { _id: new ObjectId(userId) },
  95. { readPreference: READ_PREFERENCE_SECONDARY }
  96. )
  97. if (!user) {
  98. counts.userNotFound.push(userId)
  99. continue
  100. }
  101. for (const email of candidateEmails) {
  102. if (user.emails.some(item => item.email === email)) {
  103. counts.alreadyOk.push(email)
  104. continue
  105. }
  106. const isUsed = await isEmailUsed(email)
  107. const isOwnPrimary = user.email === email
  108. if (isUsed && !isOwnPrimary) {
  109. counts.emailsInUse.push(email)
  110. continue
  111. }
  112. if (user.email === email) counts.primary.push(email)
  113. else counts.secondary.push(email)
  114. if (commit) {
  115. const auditLog = {
  116. initiatorId: null,
  117. ipAddress: null,
  118. info: {
  119. script: true,
  120. note: 'fix wrongly removed unconfirmed secondary email',
  121. },
  122. }
  123. if (isOwnPrimary) {
  124. // can't use addEmailAddress for primary email because ensureUniqueEmailAddress will throw
  125. // using an override instead
  126. await addEmailAddressOverride(user._id, email, {}, auditLog)
  127. } else {
  128. await UserUpdater.promises.addEmailAddress(
  129. user._id,
  130. email,
  131. {},
  132. auditLog
  133. )
  134. }
  135. await UserUpdater.promises.confirmEmail(user._id, email)
  136. }
  137. counts.addedEmails.push(email)
  138. }
  139. counts.processedUsers.push(userId)
  140. trackProgress(
  141. `Processed users: ${counts.processedUsers.length}/${userIds.length}`
  142. )
  143. }
  144. console.log()
  145. if (!commit) {
  146. console.log('Dry-run, use --commit to apply changes')
  147. console.log('This would be the result:')
  148. console.log()
  149. }
  150. console.log('Total emails in the CSV:', rows.length)
  151. console.log('Total users in the CSV:', userIds.length)
  152. console.log()
  153. console.log('Users not found:', counts.userNotFound.length)
  154. console.log('Users not found:', JSON.stringify(counts.userNotFound))
  155. console.log()
  156. console.log('Already OK:', counts.alreadyOk.length)
  157. console.log('Already OK:', JSON.stringify(counts.alreadyOk))
  158. console.log()
  159. console.log('Already in use:', counts.emailsInUse.length)
  160. console.log('Already in use:', JSON.stringify(counts.emailsInUse))
  161. console.log()
  162. console.log('Primary:', counts.primary.length)
  163. console.log('Primary:', JSON.stringify(counts.primary))
  164. console.log()
  165. console.log('Secondary:', counts.secondary.length)
  166. console.log('Secondary:', JSON.stringify(counts.secondary))
  167. console.log()
  168. console.log('Added emails:', counts.addedEmails.length)
  169. console.log('Added emails:', JSON.stringify(counts.addedEmails))
  170. console.log()
  171. console.log()
  172. console.timeEnd('re_add_deleted_emails')
  173. console.log()
  174. }
  175. async function main(trackProgress) {
  176. if (help) {
  177. return usage()
  178. }
  179. await consumeCsvFile(trackProgress)
  180. }
  181. try {
  182. await scriptRunner(main)
  183. process.exit(0)
  184. } catch (error) {
  185. console.error(error)
  186. process.exit(1)
  187. }
  188. async function addEmailAddressOverride(
  189. userId,
  190. newEmail,
  191. affiliationOptions,
  192. auditLog
  193. ) {
  194. AsyncLocalStorage.removeItem('userFullEmails')
  195. newEmail = EmailHelper.parseEmail(newEmail)
  196. if (!newEmail) {
  197. throw new Error('invalid email')
  198. }
  199. // Bypass ensureUniqueEmailAddress when re-adding primary emails
  200. // await UserGetter.promises.ensureUniqueEmailAddress(newEmail)
  201. AnalyticsManager.recordEventForUserInBackground(
  202. userId,
  203. 'secondary-email-added'
  204. )
  205. await UserAuditLogHandler.promises.addEntry(
  206. userId,
  207. 'add-email',
  208. auditLog.initiatorId,
  209. auditLog.ipAddress,
  210. {
  211. ...auditLog.info,
  212. newSecondaryEmail: newEmail,
  213. }
  214. )
  215. try {
  216. await InstitutionsAPI.promises.addAffiliation(
  217. userId,
  218. newEmail,
  219. affiliationOptions
  220. )
  221. } catch (error) {
  222. throw OError.tag(error, 'problem adding affiliation while adding email')
  223. }
  224. const createdAt = new Date()
  225. let res
  226. try {
  227. const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
  228. const update = {
  229. $push: {
  230. emails: { email: newEmail, createdAt, reversedHostname },
  231. },
  232. }
  233. res = await UserUpdater.promises.updateUser(
  234. { _id: userId, 'emails.email': { $ne: newEmail } },
  235. update
  236. )
  237. } catch (error) {
  238. throw OError.tag(error, 'problem updating users emails')
  239. }
  240. if (res.matchedCount !== 1) {
  241. return
  242. }
  243. try {
  244. await EmailChangeHelper.registerEmailCreation(userId, newEmail, {
  245. // @ts-expect-error - This is copied from UserUpdater.mjs
  246. createdAt: new Date(),
  247. emailCreatedAt: createdAt,
  248. })
  249. } catch (error) {
  250. logger.warn(
  251. { error, userId, newEmail },
  252. 'Error registering email creation with analytics'
  253. )
  254. }
  255. }