From dc7cd90551f3749348146e31b68387a2c825f7d2 Mon Sep 17 00:00:00 2001 From: Andres Pinto <143480783+apsantiso@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:37:24 +0100 Subject: [PATCH] feat: v2 request-deactivate via sendgrid, matching legacy flow --- lib/core/mail/SendGridMailService.ts | 55 +++++++++++++ lib/core/users/User.ts | 3 + lib/core/users/usecase.ts | 31 +++++--- lib/engine.js | 5 +- lib/server/eventBus.ts | 42 ---------- lib/server/http/index.ts | 9 ++- lib/server/http/users/controller.ts | 4 + tests/lib/core/gateway/usecase.test.ts | 10 +-- .../lib/core/mail/SendGridMailService.test.ts | 77 +++++++++++++++++++ tests/lib/core/users/usecase.test.ts | 71 ++++++++++------- tests/lib/e2e/users/users.e2e-spec.ts | 49 +++++++++++- 11 files changed, 264 insertions(+), 92 deletions(-) create mode 100644 lib/core/mail/SendGridMailService.ts create mode 100644 tests/lib/core/mail/SendGridMailService.test.ts diff --git a/lib/core/mail/SendGridMailService.ts b/lib/core/mail/SendGridMailService.ts new file mode 100644 index 000000000..aed414e42 --- /dev/null +++ b/lib/core/mail/SendGridMailService.ts @@ -0,0 +1,55 @@ +import sendGridMail, { MailService as SendGridClient } from '@sendgrid/mail'; + +export interface MailerConfig { + from?: string; + sendgrid: { + api_key: string; + sandbox_mode?: boolean; + delete_template_id: string; + }; +} + +export interface MailService { + sendDeleteUserMail: (sendTo: string, redirect: string) => Promise; +} + +const DEFAULT_FROM = 'activate@internxt.com'; + +export class SendGridMailService implements MailService { + constructor( + private config: MailerConfig, + private client: Pick = sendGridMail + ) { + this.client.setApiKey(config.sendgrid.api_key); + } + + async sendDeleteUserMail(sendTo: string, redirect: string): Promise { + const { from, sendgrid } = this.config; + + await this.client.send({ + to: sendTo, + from: { + email: from || DEFAULT_FROM, + name: 'Internxt', + }, + subject: '', + text: 'deactivate acount', + html: 'deactivate acount', + personalizations: [ + { + to: [{ email: sendTo }], + dynamicTemplateData: { + email: sendTo, + deletetion_url: redirect, + }, + }, + ], + templateId: sendgrid.delete_template_id, + mailSettings: { + sandboxMode: { + enable: sendgrid.sandbox_mode || false, + }, + }, + }, false); + } +} diff --git a/lib/core/users/User.ts b/lib/core/users/User.ts index b68673599..99cda24e6 100644 --- a/lib/core/users/User.ts +++ b/lib/core/users/User.ts @@ -18,6 +18,9 @@ export interface User { preferences?: { dnt: boolean; }; + configuration?: { + disableDeactivation?: boolean; + }; created?: Date; // Following attributes are deleted in toObject() from storj-service-storage-models: // pendingHashPass?: string | null; diff --git a/lib/core/users/usecase.ts b/lib/core/users/usecase.ts index fb719b879..cb2a44f1e 100644 --- a/lib/core/users/usecase.ts +++ b/lib/core/users/usecase.ts @@ -3,6 +3,7 @@ import { createHash, randomBytes } from 'crypto'; import { UsersRepository } from './Repository'; import { BucketsRepository } from '../buckets/Repository'; import { MailUsecase } from '../mail/usecase'; +import { MailService } from '../mail/SendGridMailService'; import { EventBus, EventBusEvents } from '../../server/eventBus'; import { FramesRepository } from '../frames/Repository'; import { BasicUser, User } from './User'; @@ -66,13 +67,22 @@ export class ResetPasswordImpersonationError extends Error { } } +export class UserDeactivationDisabledError extends Error { + constructor() { + super('This account has the deactivation disabled'); + + Object.setPrototypeOf(this, UserDeactivationDisabledError.prototype); + } +} + export class UsersUsecase { constructor( private usersRepository: UsersRepository, private framesRepository: FramesRepository, private bucketsRepository: BucketsRepository, private mailUsecase: MailUsecase, - private eventBus: EventBus + private eventBus: EventBus, + private mailService: MailService ) {} async getUserUsage(uuid: User['uuid']): Promise { @@ -227,22 +237,25 @@ export class UsersUsecase { return user; } - async requestUserDestroy(userId: string, deactivator: string, redirect: string) { + async requestUserDestroy( + userId: string, + deactivator: string, + redirect: string + ): Promise> { const user = await this.usersRepository.findById(userId); if (!user) { throw new UserNotFoundError(userId); } + if (user.configuration?.disableDeactivation) { + throw new UserDeactivationDisabledError(); + } + + await this.mailService.sendDeleteUserMail(user.email, redirect); await this.usersRepository.updateById(user.id, { deactivator }); - this.eventBus.emit(EventBusEvents.UserDestroyRequest, { - userRequestingDestroyEmail: user.email, - mailParams: { - deactivator, - redirect - } - }); + return { email: user.email, uuid: user.uuid }; } async confirmDestroyUser(deactivator: string): Promise { diff --git a/lib/engine.js b/lib/engine.js index f290bf670..0c5b4ec3b 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -15,6 +15,7 @@ const { ErrorHandlerFactory: errorHandler } = require('./server/middleware/error const Server = require('./server'); const pow = require('./server/middleware/pow'); const Mailer = require('inxt-service-mailer'); +const { SendGridMailService } = require('./core/mail/SendGridMailService'); const log = require('./logger'); const NetworkMessageQueue = require('./server/queues/networkQueue'); const { bindNewRoutes } = require('./server/http'); @@ -119,6 +120,7 @@ Engine.prototype.start = function (callback) { ); this.mailer = new Mailer(this._config.mailer); + this.mailService = new SendGridMailService(this._config.mailer); this.redis = require('redis').createClient(this._config.redis); this.redis.on('ready', () => { log.info('connected to redis'); @@ -266,7 +268,8 @@ Engine.prototype._configureApp = function () { }, log, this.networkQueue, - this.notifications + this.notifications, + this.mailService ); return app; diff --git a/lib/server/eventBus.ts b/lib/server/eventBus.ts index 9b82a2d8e..c770e4cf6 100644 --- a/lib/server/eventBus.ts +++ b/lib/server/eventBus.ts @@ -1,6 +1,5 @@ import { EventEmitter } from 'events'; import { Logger } from 'winston'; -import { MailUsecase } from '../core/mail/usecase'; import { Notifications } from './notifications'; import { User } from '../core/users/User'; @@ -9,7 +8,6 @@ const { trackUserActivated } = require('../analytics'); export enum EventBusEvents { UserCreationStarts = 'user-creation-starts', UserCreationEnds = 'user-creation-ends', - UserDestroyRequest = 'user-destroy-request', FilesBulkDeleteFailed = 'files-bulk-delete-failed', UserStorageChanged = 'user-storage-changed', } @@ -23,14 +21,6 @@ interface UserCreationEndsPayload { uuid: User['id']; } -interface UserDestroyRequestPayload { - userRequestingDestroyEmail: User['email']; - mailParams: { - deactivator: string; - redirect: string; - }; -} - interface FilesBulkDeleteFailedPayload { err: Error; fileIds: string; @@ -44,7 +34,6 @@ export interface UserStorageChangedPayload { export class EventBus extends EventEmitter { constructor( logger: Logger, - mailer: MailUsecase, notifications: Notifications ) { super(); @@ -65,37 +54,6 @@ export class EventBus extends EventEmitter { } ); - this.on( - EventBusEvents.UserDestroyRequest, - (payload: UserDestroyRequestPayload) => { - logger.info( - 'User %s requesting destroy', - payload.userRequestingDestroyEmail - ); - - mailer - .sendDeleteUserMail( - payload.userRequestingDestroyEmail, - payload.mailParams.deactivator, - payload.mailParams.redirect - ) - .then(() => { - logger.info( - 'User destroy email sent to %s', - payload.userRequestingDestroyEmail - ); - }) - .catch((err) => { - logger.error( - 'Error sending user destroy email to %s: %s. %s', - payload.userRequestingDestroyEmail, - err.message, - err.stack - ); - }); - } - ); - this.on( EventBusEvents.FilesBulkDeleteFailed, ({ err, fileIds }: FilesBulkDeleteFailedPayload) => { diff --git a/lib/server/http/index.ts b/lib/server/http/index.ts index 77891748c..588c4915d 100644 --- a/lib/server/http/index.ts +++ b/lib/server/http/index.ts @@ -6,6 +6,7 @@ import { BucketsRepository } from "../../core/buckets/Repository"; import { UsersRepository } from "../../core/users/Repository"; import { Mailer, MailUsecase, Profile, SendGridMailUsecase } from "../../core/mail/usecase"; +import { MailService } from "../../core/mail/SendGridMailService"; import { createUsersHTTPRouter } from "./users"; import { HTTPUsersController } from "./users/controller"; import { EventBus } from "../eventBus"; @@ -57,7 +58,8 @@ export function bindNewRoutes( profile: Profile, log: Logger, networkQueue: any, - notifications: Notifications + notifications: Notifications, + mailService: MailService ): void { const { models } = storage; @@ -73,14 +75,15 @@ export function bindNewRoutes( const fileStateRepository: FileStateRepository = new MongoDBFileStateRepository(models.FileState); const mailUsecase: MailUsecase = new SendGridMailUsecase(mailer, profile); - const eventBus = new EventBus(log, mailUsecase, notifications); + const eventBus = new EventBus(log, notifications); const usersUsecase = new UsersUsecase( usersRepository, framesRepository, bucketsRepository, mailUsecase, - eventBus + eventBus, + mailService ); const gatewayUsecase = new GatewayUsecase( diff --git a/lib/server/http/users/controller.ts b/lib/server/http/users/controller.ts index 8bd5c9fc9..bb1671e2b 100644 --- a/lib/server/http/users/controller.ts +++ b/lib/server/http/users/controller.ts @@ -3,6 +3,7 @@ import { Logger } from 'winston'; import { InvalidDataFormatError, + UserDeactivationDisabledError, UserNotFoundError, UsersUsecase } from '../../../core'; @@ -104,6 +105,9 @@ export class HTTPUsersController { if (err instanceof UserNotFoundError) { return res.status(404).send({ error: err.message }); } + if (err instanceof UserDeactivationDisabledError) { + return res.status(409).send({ error: err.message }); + } this.logger.error( 'Error requesting user destroy for user %s: %s. %s', (req as AuthorizedRequest).user._id, diff --git a/tests/lib/core/gateway/usecase.test.ts b/tests/lib/core/gateway/usecase.test.ts index 9a0627f55..3f4076e35 100644 --- a/tests/lib/core/gateway/usecase.test.ts +++ b/tests/lib/core/gateway/usecase.test.ts @@ -1,6 +1,5 @@ import { stub, restore } from 'sinon'; import { createLogger } from 'winston'; -import Mailer from 'inxt-service-mailer'; import { BucketEntriesRepository } from '../../../../lib/core/bucketEntries/Repository'; import { FramesRepository } from '../../../../lib/core/frames/Repository'; @@ -20,7 +19,6 @@ import { } from './fixtures'; import { Pointer } from '../../../../lib/core/pointers/Pointer'; import { EventBus, EventBusEvents } from '../../../../lib/server/eventBus'; -import { MailUsecase, SendGridMailUsecase } from '../../../../lib/core/mail/usecase'; import { ShardsRepository } from '../../../../lib/core/shards/Repository'; import { MongoDBShardsRepository } from '../../../../lib/core/shards/MongoDBShardsRepository'; import { ContactsRepository } from '../../../../lib/core/contacts/Repository'; @@ -33,10 +31,6 @@ let pointersRepository: PointersRepository = new MongoDBPointersRepository({}); let mirrorsRepository: MirrorsRepository = new MongoDBMirrorsRepository({}); let shardsRepository: ShardsRepository = new MongoDBShardsRepository({}); let contactsRepository: ContactsRepository = new MongoDBContactsRepository({}); -let mailUsecase: MailUsecase = new SendGridMailUsecase({} as Mailer, { - host: '', - protocol: 'http:', -}); let logger = createLogger({ silent: true @@ -44,7 +38,7 @@ let logger = createLogger({ let notifications = new Notifications('',''); -let eventBus = new EventBus(logger, mailUsecase, notifications); +let eventBus = new EventBus(logger, notifications); let usecase = new GatewayUsecase( bucketEntriesRepository, @@ -63,7 +57,7 @@ beforeEach(() => { mirrorsRepository = new MongoDBMirrorsRepository({}); shardsRepository = new MongoDBShardsRepository({}); contactsRepository = new MongoDBContactsRepository({}); - eventBus = new EventBus(logger, mailUsecase, notifications); + eventBus = new EventBus(logger, notifications); usecase = new GatewayUsecase( bucketEntriesRepository, diff --git a/tests/lib/core/mail/SendGridMailService.test.ts b/tests/lib/core/mail/SendGridMailService.test.ts new file mode 100644 index 000000000..ad75c9e61 --- /dev/null +++ b/tests/lib/core/mail/SendGridMailService.test.ts @@ -0,0 +1,77 @@ +import { MailerConfig, SendGridMailService } from '../../../../lib/core/mail/SendGridMailService'; + +describe('SendGridMailService', () => { + const config: MailerConfig = { + from: 'from@internxt.com', + sendgrid: { + api_key: 'api-key', + sandbox_mode: true, + delete_template_id: 'delete-template-id', + }, + }; + + const buildClient = () => ({ + setApiKey: jest.fn(), + send: jest.fn().mockResolvedValue([{}, {}]), + }); + + it('When the usecase is created, then the sendgrid api key is set', () => { + const client = buildClient(); + + new SendGridMailService(config, client); + + expect(client.setApiKey).toHaveBeenCalledWith('api-key'); + }); + + describe('sendDeleteUserMail()', () => { + it('When sending the account deletion email, then it is sent to the user with the deletion template and confirmation link', async () => { + const client = buildClient(); + const usecase = new SendGridMailService(config, client); + + await usecase.sendDeleteUserMail('user@internxt.com', 'https://drive.internxt.com/deactivations/token'); + + expect(client.send).toHaveBeenCalledTimes(1); + expect(client.send).toHaveBeenCalledWith({ + to: 'user@internxt.com', + from: { email: 'from@internxt.com', name: 'Internxt' }, + subject: '', + text: 'deactivate acount', + html: 'deactivate acount', + personalizations: [ + { + to: [{ email: 'user@internxt.com' }], + dynamicTemplateData: { + email: 'user@internxt.com', + deletetion_url: 'https://drive.internxt.com/deactivations/token', + }, + }, + ], + templateId: 'delete-template-id', + mailSettings: { sandboxMode: { enable: true } }, + }, false); + }); + + it('When no sender or sandbox mode are configured, then it uses the defaults', async () => { + const client = buildClient(); + const usecase = new SendGridMailService( + { sendgrid: { api_key: 'api-key', delete_template_id: 'tpl' } }, + client + ); + + await usecase.sendDeleteUserMail('user@internxt.com', 'redirect'); + + const [payload] = client.send.mock.calls[0]; + expect(payload.from).toStrictEqual({ email: 'activate@internxt.com', name: 'Internxt' }); + expect(payload.mailSettings).toStrictEqual({ sandboxMode: { enable: false } }); + }); + + it('When sendgrid fails, then the error is propagated', async () => { + const client = buildClient(); + client.send.mockRejectedValueOnce(new Error('sendgrid down')); + const usecase = new SendGridMailService(config, client); + + await expect(usecase.sendDeleteUserMail('user@internxt.com', 'redirect')) + .rejects.toThrow('sendgrid down'); + }); + }); +}); diff --git a/tests/lib/core/users/usecase.test.ts b/tests/lib/core/users/usecase.test.ts index 717de8093..ef8591f40 100644 --- a/tests/lib/core/users/usecase.test.ts +++ b/tests/lib/core/users/usecase.test.ts @@ -11,6 +11,7 @@ import { InvalidDataFormatError, UserAlreadyExistsError, UserNotFoundError, + UserDeactivationDisabledError, RESET_PASSWORD_TOKEN_BYTES_LENGTH, SHA256_HASH_BYTES_LENGTH, EmailIsAlreadyInUseError @@ -19,6 +20,7 @@ import { MongoDBFramesRepository } from '../../../../lib/core/frames/MongoDBFram import { FramesRepository } from '../../../../lib/core/frames/Repository'; import { BucketsRepository } from '../../../../lib/core/buckets/Repository'; import { Mailer, MailUsecase, SendGridMailUsecase } from '../../../../lib/core/mail/usecase'; +import { MailService } from '../../../../lib/core/mail/SendGridMailService'; import { EventBus, EventBusEvents } from '../../../../lib/server/eventBus'; import { User } from '../../../../lib/core/users/User'; import { Notifications } from '../../../../lib/server/notifications'; @@ -29,6 +31,7 @@ let framesRepository: FramesRepository; let bucketsRepository: BucketsRepository; let usecase: UsersUsecase; let mailUsecase: MailUsecase; +let mailService: MailService; let eventBus: EventBus; let notifications: Notifications; @@ -41,14 +44,16 @@ beforeEach(() => { protocol: 'http:', }); notifications = new Notifications('',''); - eventBus = new EventBus(createLogger(), mailUsecase, notifications); + mailService = { sendDeleteUserMail: async () => {} }; + eventBus = new EventBus(createLogger(), notifications); eventBus.removeAllListeners(); usecase = new UsersUsecase( usersRepository, framesRepository, bucketsRepository, mailUsecase, - eventBus + eventBus, + mailService ); }); @@ -356,42 +361,54 @@ describe('Users usecases', () => { }); describe('Requesting user destroy', () => { - it('When requesting a user destroy, it should work if the user exists', async () => { + const redirect = 'https://drive.internxt.com/deactivations/token'; + + it('When requesting a user destroy, then it sends the mail, saves the deactivator and returns the user', async () => { const user = fixtures.getUser(); - const redirect = 'redirect'; const findUser = stub(usersRepository, 'findById').resolves(user); + const sendMail = jest.spyOn(mailService, 'sendDeleteUserMail').mockResolvedValue(); const updateById = jest.spyOn(usersRepository, 'updateById').mockImplementation(); - const eventBusEmitterSpy = jest.spyOn(eventBus, 'emit').mockImplementation(); - await usecase.requestUserDestroy(user.id, user.deactivator as string, redirect); + const result = await usecase.requestUserDestroy(user.id, user.deactivator as string, redirect); - expect(findUser.calledOnce).toBeTruthy(); expect(findUser.firstCall.args).toStrictEqual([user.id]); - expect(updateById).toBeCalledTimes(1); + expect(sendMail).toBeCalledWith(user.email, redirect); expect(updateById).toBeCalledWith(user.id, { deactivator: user.deactivator }); - expect(eventBusEmitterSpy).toBeCalledTimes(1); - expect(eventBusEmitterSpy).toBeCalledWith(EventBusEvents.UserDestroyRequest, { - userRequestingDestroyEmail: user.email, - mailParams: { - deactivator: user.deactivator, - redirect, - } - }); + expect(sendMail.mock.invocationCallOrder[0]).toBeLessThan(updateById.mock.invocationCallOrder[0]); + expect(result).toStrictEqual({ email: user.email, uuid: user.uuid }); }); - it('When requesting a user destroy, it should fail if the user does not exist', async () => { + it('When the user does not exist, then it fails', async () => { const user = fixtures.getUser(); - const redirect = 'redirect'; - const findUser = stub(usersRepository, 'findById').resolves(null); + stub(usersRepository, 'findById').resolves(null); + const sendMail = jest.spyOn(mailService, 'sendDeleteUserMail').mockResolvedValue(); - try { - await usecase.requestUserDestroy(user.id, user.deactivator as string, redirect); - expect(true).toBeFalsy(); - } catch (err) { - expect(err).toBeInstanceOf(UserNotFoundError); - expect(findUser.calledOnce).toBeTruthy(); - expect(findUser.firstCall.args).toStrictEqual([user.id]); - } + await expect(usecase.requestUserDestroy(user.id, user.deactivator as string, redirect)) + .rejects.toBeInstanceOf(UserNotFoundError); + expect(sendMail).not.toBeCalled(); + }); + + it('When the user has the deactivation disabled, then it fails without sending the mail', async () => { + const user = { ...fixtures.getUser(), configuration: { disableDeactivation: true } }; + stub(usersRepository, 'findById').resolves(user); + const sendMail = jest.spyOn(mailService, 'sendDeleteUserMail').mockResolvedValue(); + const updateById = jest.spyOn(usersRepository, 'updateById').mockImplementation(); + + await expect(usecase.requestUserDestroy(user.id, user.deactivator as string, redirect)) + .rejects.toBeInstanceOf(UserDeactivationDisabledError); + expect(sendMail).not.toBeCalled(); + expect(updateById).not.toBeCalled(); + }); + + it('When sending the mail fails, then it fails without saving the deactivator', async () => { + const user = fixtures.getUser(); + stub(usersRepository, 'findById').resolves(user); + jest.spyOn(mailService, 'sendDeleteUserMail').mockRejectedValue(new Error('sendgrid down')); + const updateById = jest.spyOn(usersRepository, 'updateById').mockImplementation(); + + await expect(usecase.requestUserDestroy(user.id, user.deactivator as string, redirect)) + .rejects.toThrow('sendgrid down'); + expect(updateById).not.toBeCalled(); }); }); diff --git a/tests/lib/e2e/users/users.e2e-spec.ts b/tests/lib/e2e/users/users.e2e-spec.ts index 762ef1865..88d40fb68 100644 --- a/tests/lib/e2e/users/users.e2e-spec.ts +++ b/tests/lib/e2e/users/users.e2e-spec.ts @@ -143,10 +143,13 @@ describe('Bridge E2E Tests', () => { const user = await createTestUser({ user: payload }) + const sendDeleteUserMail = jest.spyOn(engine.mailService, 'sendDeleteUserMail') + const redirect = 'https://drive.internxt.com/deactivations/token' + // Act: Request Deactivation const deactivatorHash = dataGenerator.hash() const response = await testServer - .delete(`/v2/users/request-deactivate?deactivator=${deactivatorHash}&redirect=/`) + .delete(`/v2/users/request-deactivate?deactivator=${deactivatorHash}&redirect=${redirect}`) .set('Authorization', getAuth(payload)) // Assert @@ -158,8 +161,50 @@ describe('Bridge E2E Tests', () => { const token = dbUser.toObject().deactivator expect(token).toBe(deactivatorHash) - expect(sendGridMail.send).toHaveBeenCalled() + expect(sendDeleteUserMail).toHaveBeenCalledWith(user.email, redirect) + + }) + it('When requesting user deactivation, then it success', async () => { + const payload = generateTestUserData() + const user = await createTestUser({ user: payload }) + + const response = await testServer + .delete(`/v2/users/request-deactivate?deactivator=${dataGenerator.hash()}&redirect=/`) + .set('Authorization', getAuth(payload)) + + expect(response.status).toBe(200) + expect(response.body).toStrictEqual({ email: user.email, uuid: user.uuid }) + }) + + it('When the user has the deactivation disabled, then it fails without sending the email', async () => { + const payload = generateTestUserData() + const user = await createTestUser({ user: payload }) + await engine.storage.models.User.collection.updateOne({ _id: user.id }, { $set: { 'configuration.disableDeactivation': true } }) + const sendDeleteUserMail = jest.spyOn(engine.mailService, 'sendDeleteUserMail') + + const response = await testServer + .delete(`/v2/users/request-deactivate?deactivator=${dataGenerator.hash()}&redirect=/`) + .set('Authorization', getAuth(payload)) + + expect(response.status).toBe(409) + expect(sendDeleteUserMail).not.toHaveBeenCalled() + const dbUser = await engine.storage.models.User.findOne({ _id: user.id }) + expect(dbUser.toObject().deactivator).toBeNull() + }) + + it('When sending the email fails, then it fails without saving the deactivator', async () => { + jest.spyOn(engine.mailService, 'sendDeleteUserMail').mockRejectedValueOnce(new Error('sendgrid down')) + const payload = generateTestUserData() + const user = await createTestUser({ user: payload }) + + const response = await testServer + .delete(`/v2/users/request-deactivate?deactivator=${dataGenerator.hash()}&redirect=/`) + .set('Authorization', getAuth(payload)) + + expect(response.status).toBe(500) + const dbUser = await engine.storage.models.User.findOne({ _id: user.id }) + expect(dbUser.toObject().deactivator).toBeNull() }) it('When confirming user deactivation, it should work with the correct deactivator', async () => {