|
| 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. |
0 commit comments