Skip to content

Commit f4a100d

Browse files
dylanjeffersclaude
andauthored
feat(identity): add email verification flow (#14378)
## Summary - Adds native email verification to identity-service so we can drop Bouncer's deliverability check (Option A). - New `isEmailVerified`, `emailVerificationToken` (sha256-hashed), and `emailVerificationTokenCreatedAt` columns on `Users`. Bouncer / `isEmailDeliverable` are intentionally kept for backward compatibility - removal is a follow-up. - Signup now generates a token, stores its hash, and sends a verification email (24h TTL). Signups from disposable-email domains are rejected using the open-source [disposable-email-domains](https://github.com/disposable-email-domains/disposable-email-domains) blocklist embedded as a static file. - New endpoints: `GET /email/verify?token=...` (redirects to `audius.co/verify-email?status=...`) and `POST /email/resend-verification` (authed). - `recovery.js` and `welcomeEmail.js` now suppress sends when neither `isEmailVerified` nor the legacy `isEmailDeliverable` flag is true (so older accounts keep receiving mail). ## Bug fixes applied during rebase - **CRITICAL** - `buildVerificationLink` was pointing at `websiteHost` (e.g. `https://audius.co/verify-email`) instead of the identity service's own `GET /email/verify` handler. Added `identityServiceHost` config key (env `identityServiceHost`, default `https://identityservice.audius.co`) and updated the link builder to use `${identityServiceHost}/email/verify?token=...`. - **LOW** - `req.logger` may be undefined on bare routes (only `handleResponse` routes inject it). Changed `req.logger.error(...)` to `(req.logger || console).error(...)` in the catch block of `GET /email/verify`. - **REBASE** - `src/notifications/emails/` was renamed to `src/emails/` on main; resolved by moving the template to `src/emails/emailVerification.js` and updating the import in `src/utils/emailVerification.js`. ## Files - Migration: `packages/identity-service/sequelize/migrations/20260521000000-add-email-verification.js` - Model: `packages/identity-service/src/models/user.js` - Routes: `src/routes/user.js`, `src/routes/emailVerification.js`, `src/routes/recovery.js`, `src/routes/welcomeEmail.js` - Helpers: `src/utils/emailVerification.js`, `src/utils/disposableEmail.js` - Template: `src/emails/emailVerification.js` (was `src/notifications/emails/`) - Blocklist data: `src/data/disposable_email_blocklist.conf` ## Out of scope (follow-ups) - Remove Bouncer call + drop `isEmailDeliverable` column once verification ramps. - Update the anti-abuse oracle (pedalboard) to score on `isEmailVerified` instead of `isEmailDeliverable`. ## Test plan - [ ] `npm run typecheck` and `npm run lint` in `packages/identity-service` - both pass locally. - [ ] Run `db:migrate` against a dev DB; verify the three columns and index land, then run the `down` migration cleanly. - [ ] Sign up a new user; confirm a row is created with `isEmailVerified=false` and a hashed token, and a verification email is sent via Sendgrid (or skipped with a warn log when Sendgrid is unconfigured). - [ ] Click the link -> `isEmailVerified` flips to true, token columns cleared, redirected to `/verify-email?status=success`. - [ ] Tamper with the token / let it expire -> redirected to `status=invalid` / `status=expired`. - [ ] Hit `POST /email/resend-verification` while authed; new token issued, email sent. - [ ] Try signing up with `@mailinator.com` (or any address from the blocklist) -> 400 with a clear error message. - [ ] On an account with `isEmailVerified=false` and `isEmailDeliverable=true` (legacy), recovery + welcome emails still send. - [ ] On an account with both flags false, recovery + welcome emails are suppressed. Generated with Claude Code --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0c9b26f commit f4a100d

12 files changed

Lines changed: 5982 additions & 3 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use strict'
2+
3+
module.exports = {
4+
up: async (queryInterface, Sequelize) => {
5+
await queryInterface.addColumn('Users', 'isEmailVerified', {
6+
type: Sequelize.BOOLEAN,
7+
allowNull: false,
8+
defaultValue: false
9+
})
10+
await queryInterface.addColumn('Users', 'emailVerificationToken', {
11+
type: Sequelize.STRING,
12+
allowNull: true
13+
})
14+
await queryInterface.addColumn('Users', 'emailVerificationTokenCreatedAt', {
15+
type: Sequelize.DATE,
16+
allowNull: true
17+
})
18+
await queryInterface.addIndex('Users', ['emailVerificationToken'], {
19+
name: 'users_email_verification_token_idx'
20+
})
21+
},
22+
23+
down: async (queryInterface) => {
24+
await queryInterface.removeIndex(
25+
'Users',
26+
'users_email_verification_token_idx'
27+
)
28+
await queryInterface.removeColumn(
29+
'Users',
30+
'emailVerificationTokenCreatedAt'
31+
)
32+
await queryInterface.removeColumn('Users', 'emailVerificationToken')
33+
await queryInterface.removeColumn('Users', 'isEmailVerified')
34+
}
35+
}

packages/identity-service/src/config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,12 @@ const config = convict({
773773
env: 'websiteHost',
774774
default: 'https://audius.co'
775775
},
776+
identityServiceHost: {
777+
doc: 'Publicly reachable base URL of this identity service instance',
778+
format: String,
779+
env: 'identityServiceHost',
780+
default: 'https://identityservice.audius.co'
781+
},
776782
amplitudeAPIKey: {
777783
doc: 'Amplitude API key',
778784
format: String,

0 commit comments

Comments
 (0)