diff --git a/.env.sample b/.env.sample index 5bfc61b42c..b412ad5b71 100644 --- a/.env.sample +++ b/.env.sample @@ -37,6 +37,8 @@ #EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX= # The email template reference for a recovery transaction notification. #EMAIL_TEMPLATE_RECOVERY_TX= +# The email template reference for a verification code sent. +#EMAIL_TEMPLATE_VERIFICATION_CODE= # The sender name to be included in the 'from' section of the emails sent. # (default is 'Safe' if none is set) #EMAIL_API_FROM_NAME= diff --git a/docker-compose.yml b/docker-compose.yml index 171751c8b0..3c1b1208e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,8 @@ services: EMAIL_API_FROM_EMAIL: ${EMAIL_API_FROM_EMAIL-changeme@example.com} EMAIL_API_KEY: ${EMAIL_API_KEY-example_api_key} EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: ${EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX-example_template_unknown_recovery_tx} + EMAIL_TEMPLATE_RECOVERY_TX: ${EMAIL_TEMPLATE_RECOVERY_TX-example_template_recovery_tx} + EMAIL_TEMPLATE_VERIFICATION_CODE: ${EMAIL_TEMPLATE_VERIFICATION_CODE-example_template_verification_code} depends_on: - redis - db diff --git a/src/config/configuration.validator.spec.ts b/src/config/configuration.validator.spec.ts index d520916e87..baeeb65451 100644 --- a/src/config/configuration.validator.spec.ts +++ b/src/config/configuration.validator.spec.ts @@ -16,6 +16,7 @@ describe('Configuration validator', () => { EMAIL_API_KEY: faker.string.uuid(), EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), + EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), }; it('should bypass this validation on test environment', () => { @@ -42,6 +43,7 @@ describe('Configuration validator', () => { { key: 'EMAIL_API_KEY' }, { key: 'EMAIL_TEMPLATE_RECOVERY_TX' }, { key: 'EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX' }, + { key: 'EMAIL_TEMPLATE_VERIFICATION_CODE' }, ])( 'should detect that $key is missing in the configuration in production environment', ({ key }) => { @@ -67,6 +69,7 @@ describe('Configuration validator', () => { EMAIL_API_KEY: faker.string.uuid(), EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), + EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), }), ).toThrow(/LOG_LEVEL must be equal to one of the allowed values/); }); diff --git a/src/config/configuration.validator.ts b/src/config/configuration.validator.ts index 8d034cfb2b..6f667b9dba 100644 --- a/src/config/configuration.validator.ts +++ b/src/config/configuration.validator.ts @@ -18,6 +18,7 @@ const configurationSchema: Schema = { EMAIL_API_KEY: { type: 'string' }, EMAIL_TEMPLATE_RECOVERY_TX: { type: 'string' }, EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: { type: 'string' }, + EMAIL_TEMPLATE_VERIFICATION_CODE: { type: 'string' }, }, required: [ 'AUTH_TOKEN', @@ -30,6 +31,7 @@ const configurationSchema: Schema = { 'EMAIL_API_KEY', 'EMAIL_TEMPLATE_RECOVERY_TX', 'EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX', + 'EMAIL_TEMPLATE_VERIFICATION_CODE', ], }; diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 820e7e7bd5..b5c3aa956d 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -36,6 +36,7 @@ export default (): ReturnType => ({ templates: { recoveryTx: faker.string.alphanumeric(), unknownRecoveryTx: faker.string.alphanumeric(), + verificationCode: faker.string.alphanumeric(), }, verificationCode: { resendLockWindowMs: faker.number.int(), diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 0c7b416b1c..269cbe30f2 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -34,6 +34,7 @@ export default () => ({ templates: { recoveryTx: process.env.EMAIL_TEMPLATE_RECOVERY_TX, unknownRecoveryTx: process.env.EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX, + verificationCode: process.env.EMAIL_TEMPLATE_VERIFICATION_CODE, }, verificationCode: { resendLockWindowMs: parseInt( diff --git a/src/domain/email/email.repository.ts b/src/domain/email/email.repository.ts index 683ccf3d89..eeccbea54b 100644 --- a/src/domain/email/email.repository.ts +++ b/src/domain/email/email.repository.ts @@ -15,6 +15,7 @@ import { IEmailApi } from '@/domain/interfaces/email-api.interface'; export class EmailRepository implements IEmailRepository { private readonly verificationCodeResendLockWindowMs: number; private readonly verificationCodeTtlMs: number; + private static readonly VERIFICATION_CODE_EMAIL_SUBJECT = 'Verification code'; constructor( @Inject(IEmailDataSource) @@ -121,6 +122,7 @@ export class EmailRepository implements IEmailRepository { await this._sendEmailVerification({ ...args, code: email.verificationCode, + emailAddress: email.emailAddress.value, }); } @@ -197,12 +199,21 @@ export class EmailRepository implements IEmailRepository { } private async _sendEmailVerification(args: { - chainId: string; - safeAddress: string; account: string; + chainId: string; code: string; + emailAddress: string; + safeAddress: string; }) { - // TODO send email via provider + await this.emailApi.createMessage({ + to: [args.emailAddress], + template: this.configurationService.getOrThrow( + 'email.templates.verificationCode', + ), + subject: EmailRepository.VERIFICATION_CODE_EMAIL_SUBJECT, + substitutions: { verificationCode: args.code }, + }); + // Update verification-sent date on a successful response await this.emailDataSource.setVerificationSentDate({ ...args, diff --git a/src/routes/email/email.controller.save-email.spec.ts b/src/routes/email/email.controller.save-email.spec.ts index fb56bb7b92..dccc7040a0 100644 --- a/src/routes/email/email.controller.save-email.spec.ts +++ b/src/routes/email/email.controller.save-email.spec.ts @@ -20,12 +20,17 @@ import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { getAddress } from 'viem'; import { EmailControllerModule } from '@/routes/email/email.controller.module'; +import { IEmailApi } from '@/domain/interfaces/email-api.interface'; +import { TestEmailApiModule } from '@/datasources/email-api/__tests__/test.email-api.module'; +import { EmailApiModule } from '@/datasources/email-api/email-api.module'; describe('Email controller save email tests', () => { let app; - let safeConfigUrl; + let configurationService; + let emailApi; let emailDatasource; let networkService; + let safeConfigUrl; beforeEach(async () => { jest.clearAllMocks(); @@ -34,6 +39,8 @@ describe('Email controller save email tests', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration), EmailControllerModule], }) + .overrideModule(EmailApiModule) + .useModule(TestEmailApiModule) .overrideModule(EmailDataSourceModule) .useModule(TestEmailDatasourceModule) .overrideModule(CacheModule) @@ -44,8 +51,9 @@ describe('Email controller save email tests', () => { .useModule(TestNetworkModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); + configurationService = moduleFixture.get(IConfigurationService); safeConfigUrl = configurationService.get('safeConfig.baseUri'); + emailApi = moduleFixture.get(IEmailApi); emailDatasource = moduleFixture.get(IEmailDataSource); networkService = moduleFixture.get(NetworkService); @@ -101,6 +109,16 @@ describe('Email controller save email tests', () => { }) .expect(201) .expect({}); + + expect(emailApi.createMessage).toHaveBeenCalledTimes(1); + expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { + subject: 'Verification code', + substitutions: { verificationCode: expect.any(String) }, + template: configurationService.getOrThrow( + 'email.templates.verificationCode', + ), + to: [emailAddress], + }); }); it('returns 403 is message was signed with a timestamp older than 5 minutes', async () => {