Batch invitation mode gives one email address a link to a personal election list. The magic link is an access key for that list; it is not the credential consumed when a ballot is submitted.
flowchart LR EMAIL["Batch invitation email"] -->|"email + magic token"| LIST["/vote/my-elections"] LIST --> INVITES[("Invites for that email")] INVITES --> ONE["Election A + voting token A"] INVITES --> TWO["Election B + voting token B"] ONE --> VOTEA["Ballot submission A"] TWO --> VOTEB["Ballot submission B"]
Each election retains its own one-use voting token. The election-list page places that token into the corresponding vote link.
Create or reuse a link
When a batch invite or reminder is sent, the Worker looks for the newest link that:
- matches the normalized lowercase email;
- has
expires_at > now; and - has
used_at IS NULL.
If none exists, it creates a token by concatenating two crypto.randomUUID() values and sets expiry to seven days after creation.
The email URL is:
{BASE_URL}/vote/my-elections?email={encoded_email}&token={encoded_magic_token}Stored record
email_magic_links contains:
| Column | Meaning |
|---|---|
id | Record UUID |
email | Lowercase recipient address |
magic_token | Plaintext token with a unique constraint |
expires_at | Expiry time in milliseconds |
used_at | Optional revocation/use marker |
created_at | Creation time |
The token is stored in plaintext because the route performs an exact lookup. It is also present in the emailed URL and browser query string.
Election-list validation
GET /vote/my-elections checks:
| Check | Failure page |
|---|---|
Both email and token exist | Invalid Link |
| Email matches a basic address regular expression | Invalid Link |
Token exists in email_magic_links | Invalid Link |
expires_at >= now | Link Expired |
| Stored and requested email match case-insensitively | Invalid Link |
| At least one associated election has not closed | Link Expired |
The route loads invitations in PENDING, QUEUED, or SENT state for the email. It renders open, upcoming, and closed elections in separate groups, with one of:
- a vote link containing the election’s plaintext token;
- an already-voted marker;
- an upcoming notice; or
- a results link for a closed election.
The page never queries invitations for another email address.
Link lifetime
The current route does not inspect magicLink.used_at and does not call markMagicLinkUsed. A link therefore remains reusable until its timestamp expires or every associated election closes.
This differs from the current email template, which tells recipients the link can be used only once. The D1 helper and integration tests contain a used-link mechanism, but it is not connected to the request path.
Voting tokens have a separate lifetime: the Durable Object marks each one used on accepted submission. Reopening the election-list link after voting shows that election as voted because the page checks tokens.used_at.
Invitation modes
elections.invite_mode accepts:
| Value | Email behavior |
|---|---|
individual | One election-specific voting link |
batch | One magic link listing active invitations for the email |
An invite request can override the stored mode with invite_mode. Bulk invites also support both paths.
Batch email generation includes all non-closed invitations currently returned for that recipient, not only the election that triggered the send. The list can therefore grow when the same email is invited to another election.
Security boundary
- Possession of both query values grants access to every eligible election token shown for that email.
- Changing only the email parameter fails because it must match the email stored with the magic token.
- The page and email URL expose the magic token to browser history, logs, and any system that receives the full URL.
- Ballot rows still contain no magic token, voting token, or email address.
Current constraints
- Used-link rejection is not enforced even though
used_at,markMagicLinkUsed, and tests for that behavior exist. - The seven-day lifetime is hardcoded rather than configured per deployment or invitation.
- There is no admin route to revoke one magic link; setting
used_atrequires a database operation. - The route renders closed elections whenever at least one associated election remains open or upcoming.
Sources
Verified against ihnyc-rc-vote commit 2451aa1.
src/routes/vote.tssrc/routes/admin.tssrc/index.tssrc/templates/vote-my-elections.tsxsrc/utils/db.tsmigrations/011_batch_invites.sqltests/unit/magic-link.test.tstests/integration/magic-link-security.test.tstests/integration/batch-invite-flow.test.ts