Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,10 @@ JWT_SECRET="my-secret"
LEGACY_BLOWFISH_KEY=!!!_REPLACE_WITH_BASE64_ENCODED_KEY_!!!

## SENDGRID
SENDGRID_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID="d-73c29be82bfa4d68beea2208b6a3c4b2"
SENDGRID_SELFSERVICE_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID="d-73c29be82bfa4d68beea2208b6a3c4b2"
SENDGRID_WELCOME_EMAIL_TEMPLATE_ID="d-26c8962fb48c42a3997053ebe5954516"
SENDGRID_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID="d-73c29be82bfa4d68beea2208b6a3c4b2"
SENDGRID_SELFSERVICE_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID="d-73c29be82bfa4d68beea2208b6a3c4b2"
SENDGRID_TEMPLATE_ID_OTP_CODE="d-2d0ab9f6c9cc4efba50080668a9c35c1"
SENDGRID_WELCOME_EMAIL_TEMPLATE_ID="d-26c8962fb48c42a3997053ebe5954516"
SENDGRID_SELFSERVICE_WELCOME_EMAIL_TEMPLATE_ID="d-26c8962fb48c42a3997053ebe5954516"

SSO_TOKEN_SALT=change-me
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ The following table summarizes the environment variables used by the application
| `SLACK_CHANNEL_ID` | Default Slack channel ID for sending notifications. | `C04ENKCU4TZ` (example) |
| | **SendGrid Integration** | |
| `SENDGRID_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID` | SendGrid template ID for resend activation email. | `d-73c29be82bfa4d68beea2208b6a3c4b2` (example) |
| `SENDGRID_TEMPLATE_ID_OTP_CODE` | SendGrid template ID shared with wallet one-time-password emails. | `d-2d0ab9f6c9cc4efba50080668a9c35c1` |
| `SENDGRID_WELCOME_EMAIL_TEMPLATE_ID` | SendGrid template ID for welcome email. | `d-26c8962fb48c42a3997053ebe5954516` (example) |
| `EMAIL_CHANGE_OTP_EXPIRY_SECONDS` | Lifetime of the code sent to the current primary email. | `600` |
| `EMAIL_CHANGE_OTP_RESEND_SECONDS` | Minimum delay between current-email code requests. | `60` |
Expand Down
21 changes: 17 additions & 4 deletions src/api/user/email-change.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ describe('EmailChangeService', () => {
user: {
findUnique: jest.fn().mockImplementation(({ select }) =>
Promise.resolve({
first_name: 'Justin',
handle: 'memberHandle',
last_name: 'Gasper',
...(select?.status && { status: MemberStatus.ACTIVE }),
}),
),
Expand All @@ -59,7 +61,6 @@ describe('EmailChangeService', () => {
EMAIL_CHANGE_PROOF_EXPIRY_SECONDS: '600',
EMAIL_CHANGE_VALIDATION_EXPIRY_SECONDS: '3600',
JWT_SECRET: 'email-change-test-secret',
SENDGRID_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID: 'template-id',
};
return values[key];
}),
Expand Down Expand Up @@ -90,9 +91,21 @@ describe('EmailChangeService', () => {
});

const otpEmailPayload = eventService.postDirectBusMessage.mock.calls[0][1];
const otp = otpEmailPayload.data.code;
const otp = otpEmailPayload.data.otp;
expect(otp).toMatch(/^\d{6}$/);
expect(otpEmailPayload.recipients).toEqual(['old@example.com']);
expect(otpEmailPayload).toEqual({
data: {
name: 'Justin Gasper',
otp,
},
from: {
email: 'noreply@topcoder-dev.com',
name: 'Topcoder',
},
recipients: ['old@example.com'],
sendgrid_template_id: 'd-2d0ab9f6c9cc4efba50080668a9c35c1',
version: 'v3',
});

const proof = await service.verifyCurrentEmailOtp('2', otp);
expect(proof.verificationToken).toEqual(expect.any(String));
Expand Down Expand Up @@ -146,7 +159,7 @@ describe('EmailChangeService', () => {

it('rejects validation if the primary email changed while pending', async () => {
await service.sendCurrentEmailOtp('2');
const otp = eventService.postDirectBusMessage.mock.calls[0][1].data.code;
const otp = eventService.postDirectBusMessage.mock.calls[0][1].data.otp;
const proof = await service.verifyCurrentEmailOtp('2', otp);
await service.initiateEmailChange(
'2',
Expand Down
43 changes: 25 additions & 18 deletions src/api/user/email-change.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const DEFAULT_OTP_EXPIRY_SECONDS = 10 * 60;
const DEFAULT_OTP_RESEND_SECONDS = 60;
const DEFAULT_PROOF_EXPIRY_SECONDS = 10 * 60;
const DEFAULT_VALIDATION_EXPIRY_SECONDS = 60 * 60;
const DEFAULT_SENDGRID_OTP_TEMPLATE_ID =
'd-2d0ab9f6c9cc4efba50080668a9c35c1';
const MAX_OTP_ATTEMPTS = 5;

interface CachedOtp {
Expand Down Expand Up @@ -146,7 +148,12 @@ export class EmailChangeService {

const user = await this.prismaClient.user.findUnique({
where: { user_id: userId },
select: { handle: true, status: true },
select: {
first_name: true,
handle: true,
last_name: true,
status: true,
},
});

if (!user) {
Expand Down Expand Up @@ -187,8 +194,12 @@ export class EmailChangeService {
);

try {
const recipientName = [user.first_name, user.last_name]
.map((name) => name?.trim())
.filter(Boolean)
.join(' ') || user.handle;
await this.sendOtpEmail(
user.handle,
recipientName,
currentPrimaryEmail.address,
otp,
);
Expand Down Expand Up @@ -544,37 +555,33 @@ export class EmailChangeService {

/**
* Publishes the current-email OTP through the configured SendGrid template.
* @param handle Topcoder handle used by the email template.
* @param recipientName member name used by the wallet OTP email template.
* @param email current primary email recipient.
* @param otp six-digit ownership code.
* @returns a promise resolved after the event is published.
* @throws InternalServerErrorException when the template is not configured.
*/
private async sendOtpEmail(
handle: string,
recipientName: string,
email: string,
otp: string,
): Promise<void> {
const sendgridTemplateId = this.configService.get<string>(
'SENDGRID_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID',
);
if (!sendgridTemplateId) {
throw new InternalServerErrorException(
'Email verification template is not configured.',
);
}
const sendgridTemplateId =
this.configService.get<string>('SENDGRID_TEMPLATE_ID_OTP_CODE') ||
DEFAULT_SENDGRID_OTP_TEMPLATE_ID;

const domain = CommonUtils.getAppDomain(this.configService);
await this.eventService.postDirectBusMessage('external.action.email', {
data: {
code: otp,
duration: Math.ceil(this.otpExpirySeconds / 60),
handle,
name: recipientName,
otp,
},
from: {
email: `noreply@${domain}`,
name: 'Topcoder',
},
from: { email: `Topcoder <noreply@${domain}>` },
recipients: [email],
sendgrid_template_id: sendgridTemplateId,
version: 'v6',
version: 'v3',
});
}

Expand Down
Loading