Skip to content

Commit 3f4bdf6

Browse files
authored
Multiple Email Support (#179)
* Agent implementation plan * Codex attempt * Remove unused * Remove unused * Use EmailAddress type for verification form * We don't need the primary email on the User currently, better to keep it simple with the sign in email * Refine the add emails form to take the user back to the members page rather than their own * Start to bring the email address editing for the /me page inline with how other settings such as form of address are edited * Structured the email addresses table * Working form to add email * Add email working * Sort out send-email-verification form as command * Setting up rate limiting on sending verification emails * Correcting code around commands to better follow existing structure * Hook up sending verification email * Sorting out the split between login and verify-email * Getting landing page for email verification working * Fix import * Sorted out the landing page for verification - intentionally stepped away from completely fp/ts as part of a general move away * Fix verify email misused promises * Fix import loop * Landing page for verifying email working * Change wording for setting primary email * Fix set primary email button * Test that demonstrates issue that both types of magic token are interchangable * Prevent magic tokens being interchangable * Test + fix for add email form when adding email as a super-user * Test for send email verification event not being handled * Fix email verification requested not updating shared read model * Use the normalised email when updating verification requested in the table * Fix verification requested state not showing correctly
1 parent 76dd26e commit 3f4bdf6

File tree

82 files changed

+2158
-221
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

82 files changed

+2158
-221
lines changed

agent plans/multiple-emails.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Multi-Email Member Accounts With Verified Primary Email
2+
3+
## Summary
4+
- Add support for multiple email addresses per member, with one selected `primaryEmailAddress`.
5+
- New emails are always added as `unverified`.
6+
- Only verified emails can be used for login and only verified emails can be selected as primary.
7+
- Users cannot remove emails.
8+
- Existing `MemberNumberLinkedToEmail` history remains valid by projecting it as a verified primary email, so current users continue to work without migration of the event store.
9+
10+
## Key Changes
11+
- **Domain events and invariants**
12+
- Add `MemberEmailAdded`, `MemberEmailVerificationRequested`, `MemberEmailVerified`, and `MemberPrimaryEmailChanged`.
13+
- Keep `MemberNumberLinkedToEmail` as a legacy bootstrap event only.
14+
- Enforce these rules in command processing:
15+
- email addresses are globally unique across all members, including unverified ones
16+
- adding an already-linked email is a no-op for the same member and a failure event for a different member
17+
- `MemberPrimaryEmailChanged` is allowed only when the target email belongs to the member and is verified
18+
- verification is idempotent
19+
- Keep self-service commands authorized with the existing self-or-privileged pattern.
20+
21+
- **Read model and types**
22+
- Replace the single-email assumption in the shared member model with:
23+
- `primaryEmailAddress: EmailAddress`
24+
- `emails: ReadonlyArray<MemberEmail>`
25+
- Introduce a `MemberEmail` shape with `emailAddress`, `verifiedAt: O.Option<Date>`, and `addedAt: Date`.
26+
- Add a dedicated `memberEmails` table in the shared read model and store `primaryEmailAddress` on `members` for cheap rendering and stable downstream access.
27+
- Project legacy `MemberNumberLinkedToEmail` into:
28+
- a `members` row if missing
29+
- a verified `memberEmails` row
30+
- `members.primaryEmailAddress`
31+
- Update member merge behavior so grouped member numbers merge all email rows and expose one primary email for the merged member. Primary selection should come from the highest-priority merged record, matching the current precedence pattern used in `mergeMemberCore`.
32+
33+
- **Authentication and verification flow**
34+
- Keep `POST /auth` as the login entrypoint.
35+
- Change login lookup to search verified emails only; unverified emails behave as “no member associated”.
36+
- Continue sending the login email to the matched verified address, not to the member’s primary email unless they are the same.
37+
- Extend the session and JWT user payload to include:
38+
- `memberNumber`
39+
- `emailAddress` as the authenticated email used for that login
40+
- `primaryEmailAddress`
41+
- Add a separate email-verification token flow using a distinct token purpose from magic-link login.
42+
- Add routes:
43+
- `GET /auth/verify-email/landing`
44+
- `GET /auth/verify-email/callback`
45+
- an invalid-verification-link page or reuse the existing invalid-link pattern with verification-specific copy
46+
- Verification tokens should carry `memberNumber`, `emailAddress`, purpose, and expiry.
47+
- Reuse the existing email rate limiter for verification emails.
48+
49+
- **UI and commands**
50+
- Update the `/me` page to show:
51+
- primary email prominently
52+
- a table/list of all email addresses with `Verified` / `Unverified` status
53+
- an add-email form
54+
- a `Send verification email` action for unverified emails
55+
- a `Make primary` action for verified non-primary emails
56+
- Do not render any remove action.
57+
- Mirror the multi-email display anywhere a member profile currently shows only `emailAddress`, but use `primaryEmailAddress` where the screen only has room for one canonical address.
58+
- Add commands/forms under the existing `members` route family:
59+
- `members/add-email`
60+
- `members/send-email-verification`
61+
- `members/change-primary-email`
62+
- Use `primaryEmailAddress` everywhere the app currently means “default displayed member email”, including Gravatar, member tables, and training/admin views.
63+
64+
- **Compatibility and downstream behavior**
65+
- Update `sharedReadModel.members.findByEmail` to read from `memberEmails` and return only members with a verified match.
66+
- Update any code that currently reads `member.emailAddress` to either:
67+
- `member.primaryEmailAddress` for display/default contact behavior, or
68+
- `member.emails` when the feature actually needs the full set
69+
- Update Recurly subscription application so it resolves membership status through verified emails only. It must not activate a member based on an unverified address.
70+
- Preserve the existing admin “link email + member number” workflow by making it project as an immediately verified primary email for bootstrap/import use.
71+
72+
## Public Interfaces and Types
73+
- `User` gains `primaryEmailAddress` while keeping `emailAddress` as the authenticated email for the current session.
74+
- `MemberCoreInfo` and `Member` replace the single `emailAddress` field with `primaryEmailAddress` plus `emails`.
75+
- `SharedReadModel.members.findByEmail(email)` keeps the same signature but now matches verified emails only.
76+
- New form/command inputs:
77+
- `AddMemberEmail { memberNumber, email }`
78+
- `SendMemberEmailVerification { memberNumber, email }`
79+
- `ChangeMemberPrimaryEmail { memberNumber, email }`
80+
81+
## Test Plan
82+
- **Command tests**
83+
- add unverified email to a member
84+
- reject add when email belongs to another member
85+
- no-op when re-adding same email to same member
86+
- reject primary change to unverified email
87+
- allow primary change to verified email
88+
- verification is idempotent
89+
- **Read-model tests**
90+
- legacy `MemberNumberLinkedToEmail` produces a verified primary email
91+
- grouped/rejoined members expose all emails after merge
92+
- only verified emails are returned by `findByEmail`
93+
- primary email projection changes correctly after `MemberPrimaryEmailChanged`
94+
- **Authentication tests**
95+
- login succeeds with any verified email
96+
- login fails for unverified email
97+
- login email is sent to the matched verified address
98+
- verification token expiry and invalid token handling
99+
- session decoding works with the new `User` shape
100+
- **Query/render tests**
101+
- `/me` shows primary email and all addresses with status labels
102+
- unverified rows show verification action only
103+
- verified non-primary rows show `Make primary`
104+
- pages that previously rendered `member.emailAddress` now render `member.primaryEmailAddress`
105+
106+
## Assumptions and Defaults
107+
- Email uniqueness is global across all member emails, not just verified emails.
108+
- Users cannot delete emails in v1.
109+
- A member must always have a primary email once they have at least one email.
110+
- Legacy linked emails are treated as verified and primary automatically.
111+
- `primaryEmailAddress` is the canonical display/default-contact email; `emailAddress` on the session user is the email used to authenticate that session.
112+
- Recurly and any other email-based external syncs should trust verified emails only.

src/authentication/auth-routes.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
11
import {Dependencies} from '../dependencies';
2-
import {Safe, safe} from '../types/html';
3-
import {Route, get, post} from '../types/route';
4-
import {auth, landing, callback, invalidLink, logIn, logOut} from './handlers';
2+
import {Config} from '../configuration';
3+
import {Route} from '../types/route';
4+
import { routes as verifyEmailRoutes } from './verify-email/routes';
5+
import { loginRoutes } from './login/routes';
56

6-
export const logInPath: Safe = safe('/log-in');
7-
const invalidLinkPath = '/auth/invalid-magic-link';
8-
9-
export const authRoutes = (deps: Dependencies): ReadonlyArray<Route> => {
7+
export const authRoutes = (
8+
deps: Dependencies,
9+
conf: Config
10+
): ReadonlyArray<Route> => {
1011
return [
11-
get(logInPath, logIn(deps)),
12-
get('/log-out', logOut),
13-
post('/auth', auth),
14-
get('/auth', (_req, res) => res.redirect(logInPath)),
15-
get('/auth/landing', landing),
16-
get('/auth/callback', callback(invalidLinkPath)),
17-
get(invalidLinkPath, invalidLink(logInPath)),
12+
...loginRoutes(deps),
13+
...verifyEmailRoutes(deps, conf),
1814
];
1915
};

src/authentication/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export {magicLink} from './magic-link';
1+
export {magicLink} from './login/magic-link';
22
export {authRoutes} from './auth-routes';
3-
export {getUserFromSession} from './get-user-from-session';
4-
export {startMagicLinkEmailPubSub} from './start-magic-link-email-pub-sub';
3+
export {getUserFromSession} from './login/get-user-from-session';
4+
export {startMagicLinkEmailPubSub} from './login/start-magic-link-email-pub-sub';
55
export {sessionOptions as sessionConfig} from './session-config';
66
export {cookieSessionPassportWorkaround} from './cookie-session-passport-workaround';

src/authentication/check-your-mail.ts renamed to src/authentication/login/check-your-mail.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {pipe} from 'fp-ts/lib/function';
2-
import {isolatedPageTemplate} from '../templates/page-template';
3-
import {html, safe, sanitizeString} from '../types/html';
4-
import {EmailAddress} from '../types';
5-
import {normaliseEmailAddress} from '../read-models/shared-state/normalise-email-address';
2+
import {isolatedPageTemplate} from '../../templates/page-template';
3+
import {html, safe, sanitizeString} from '../../types/html';
4+
import {EmailAddress} from '../../types';
5+
import {normaliseEmailAddress} from '../../read-models/shared-state/normalise-email-address';
66

77
export const checkYourMailPage = (submittedEmailAddress: EmailAddress) =>
88
pipe(

src/authentication/get-user-from-session.ts renamed to src/authentication/login/get-user-from-session.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {flow, pipe} from 'fp-ts/lib/function';
22
import * as t from 'io-ts';
3-
import {User} from '../types';
3+
import {User} from '../../types';
44
import * as E from 'fp-ts/Either';
55
import * as O from 'fp-ts/Option';
6-
import {Dependencies} from '../dependencies';
6+
import {Dependencies} from '../../dependencies';
77
import {formatValidationErrors} from 'io-ts-reporters';
88

99
const SessionCodec = t.strict({
Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,16 @@ import passport from 'passport';
99
import {magicLink} from './magic-link';
1010
import {logInPage} from './log-in-page';
1111
import {checkYourMailPage} from './check-your-mail';
12-
import {oopsPage, isolatedPageTemplate} from '../templates';
12+
import {oopsPage, isolatedPageTemplate} from '../../templates';
1313
import {StatusCodes} from 'http-status-codes';
1414
import {getUserFromSession} from './get-user-from-session';
15-
import {Dependencies} from '../dependencies';
15+
import {Dependencies} from '../../dependencies';
1616
import {
1717
html,
18-
HtmlSubstitution,
1918
CompleteHtmlDocument,
2019
sanitizeString,
2120
safe,
22-
} from '../types/html';
21+
} from '../../types/html';
2322

2423
export const logIn =
2524
(deps: Dependencies) =>
@@ -57,19 +56,6 @@ export const auth = (req: Request, res: Response<CompleteHtmlDocument>) => {
5756
);
5857
};
5958

60-
export const invalidLink =
61-
(logInPath: HtmlSubstitution) =>
62-
(req: Request, res: Response<CompleteHtmlDocument>) => {
63-
res
64-
.status(StatusCodes.UNAUTHORIZED)
65-
.send(
66-
oopsPage(
67-
html`The link you have used is (no longer) valid. Go back to the
68-
<a href=${logInPath}>log in</a> page.`
69-
)
70-
);
71-
};
72-
7359
export const landing = (req: Request, res: Response<CompleteHtmlDocument>) => {
7460
const index = req.originalUrl.indexOf('?');
7561
const suffix = index === -1 ? '' : req.originalUrl.slice(index);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {Request, Response} from 'express';
2+
import {oopsPage} from '../../templates';
3+
import {StatusCodes} from 'http-status-codes';
4+
import {
5+
html,
6+
HtmlSubstitution,
7+
CompleteHtmlDocument,
8+
} from '../../types/html';
9+
10+
11+
export const invalidLink =
12+
(logInPath: HtmlSubstitution) =>
13+
(req: Request, res: Response<CompleteHtmlDocument>) => {
14+
res
15+
.status(StatusCodes.UNAUTHORIZED)
16+
.send(
17+
oopsPage(
18+
html`The link you have used is (no longer) valid. Go back to the
19+
<a href=${logInPath}>log in</a> page.`
20+
)
21+
);
22+
};
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {pipe} from 'fp-ts/lib/function';
2-
import {html, safe} from '../types/html';
3-
import {isolatedPageTemplate} from '../templates/page-template';
2+
import {html, safe} from '../../types/html';
3+
import {isolatedPageTemplate} from '../../templates/page-template';
44

55
export const logInPage = pipe(
66
html`
Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,44 @@
11
import {pipe} from 'fp-ts/lib/function';
22
import * as E from 'fp-ts/Either';
33
import * as t from 'io-ts';
4-
import {failure} from '../types';
54
import {Strategy as CustomStrategy} from 'passport-custom';
6-
import jwt from 'jsonwebtoken';
7-
import {Config} from '../configuration';
8-
import {Dependencies} from '../dependencies';
9-
import {User} from '../types/user';
10-
import {logPassThru} from '../util';
5+
import {Config} from '../../configuration';
6+
import {Dependencies} from '../../dependencies';
7+
import {User} from '../../types/user';
8+
import {logPassThru} from '../../util';
119
import {Logger} from 'pino';
10+
import {createSignedToken, verifyToken} from '../signed-token';
11+
import { EmailAddressCodec } from '../../types';
12+
13+
const MagicLinkTokenPayload = t.strict({
14+
memberNumber: t.number,
15+
emailAddress: EmailAddressCodec,
16+
purpose: t.literal('MagicLinkToken')
17+
});
18+
type MagicLinkTokenPayload = t.TypeOf<typeof MagicLinkTokenPayload>;
1219

1320
const createMagicLink = (conf: Config) => (user: User) =>
1421
pipe(
15-
jwt.sign(user, conf.TOKEN_SECRET, {expiresIn: '10m'}),
22+
{
23+
...user,
24+
purpose: 'MagicLinkToken',
25+
},
26+
createSignedToken<MagicLinkTokenPayload>(conf, '10m'),
1627
token => `${conf.PUBLIC_URL}/auth/landing?token=${token}`
1728
);
1829

1930
const MagicLinkQuery = t.strict({
2031
token: t.string,
2132
});
2233

23-
const verifyToken = (token: string, secret: Config['TOKEN_SECRET']) =>
24-
E.tryCatch(
25-
() => jwt.verify(token, secret),
26-
failure('Could not verify token')
27-
);
28-
29-
const decodeMagicLinkFromQuery =
34+
export const decodeMagicLinkFromQuery =
3035
(logger: Logger, conf: Config) => (input: unknown) =>
3136
pipe(
3237
input,
3338
logPassThru(logger, 'Attempting to decode magic link from query'), // Logging is required as a basic form of auth enumeration detection.
3439
MagicLinkQuery.decode,
3540
E.chainW(({token}) => verifyToken(token, conf.TOKEN_SECRET)),
36-
E.chainW(User.decode)
41+
E.chainW(MagicLinkTokenPayload.decode)
3742
);
3843

3944
const strategy = (deps: Dependencies, conf: Config) => {

src/authentication/parse-email-address-from-body.ts renamed to src/authentication/login/parse-email-address-from-body.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as E from 'fp-ts/Either';
22
import {flow, pipe} from 'fp-ts/lib/function';
33
import * as t from 'io-ts';
44
import {formatValidationErrors} from 'io-ts-reporters';
5-
import {EmailAddress, EmailAddressCodec} from '../types';
5+
import {EmailAddress, EmailAddressCodec} from '../../types';
66

77
const BodyCodec = t.type({
88
email: EmailAddressCodec,

0 commit comments

Comments
 (0)