Skip to content

Commit

Permalink
Send verification code email message (safe-global#1013)
Browse files Browse the repository at this point in the history
- Adds `email.templates.verificationCode` configuration key, filled by the mandatory environment variable `EMAIL_TEMPLATE_VERIFICATION_CODE`, which holds the template reference identification in the email provider system.
- Implements verification codes sending in `EmailRepository`, and adds associated test functions.
  • Loading branch information
hectorgomezv authored Jan 12, 2024
1 parent 24138c4 commit c97b8bb
Show file tree
Hide file tree
Showing 8 changed files with 45 additions and 5 deletions.
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ services:
EMAIL_API_FROM_EMAIL: ${[email protected]}
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
Expand Down
3 changes: 3 additions & 0 deletions src/config/configuration.validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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 }) => {
Expand All @@ -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/);
});
Expand Down
2 changes: 2 additions & 0 deletions src/config/configuration.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -30,6 +31,7 @@ const configurationSchema: Schema = {
'EMAIL_API_KEY',
'EMAIL_TEMPLATE_RECOVERY_TX',
'EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX',
'EMAIL_TEMPLATE_VERIFICATION_CODE',
],
};

Expand Down
1 change: 1 addition & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default (): ReturnType<typeof configuration> => ({
templates: {
recoveryTx: faker.string.alphanumeric(),
unknownRecoveryTx: faker.string.alphanumeric(),
verificationCode: faker.string.alphanumeric(),
},
verificationCode: {
resendLockWindowMs: faker.number.int(),
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 14 additions & 3 deletions src/domain/email/email.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -121,6 +122,7 @@ export class EmailRepository implements IEmailRepository {
await this._sendEmailVerification({
...args,
code: email.verificationCode,
emailAddress: email.emailAddress.value,
});
}

Expand Down Expand Up @@ -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,
Expand Down
22 changes: 20 additions & 2 deletions src/routes/email/email.controller.save-email.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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)
Expand All @@ -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);

Expand Down Expand Up @@ -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 () => {
Expand Down

0 comments on commit c97b8bb

Please sign in to comment.