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.

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:

ColumnMeaning
idRecord UUID
emailLowercase recipient address
magic_tokenPlaintext token with a unique constraint
expires_atExpiry time in milliseconds
used_atOptional revocation/use marker
created_atCreation 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:

CheckFailure page
Both email and token existInvalid Link
Email matches a basic address regular expressionInvalid Link
Token exists in email_magic_linksInvalid Link
expires_at >= nowLink Expired
Stored and requested email match case-insensitivelyInvalid Link
At least one associated election has not closedLink 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.

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:

ValueEmail behavior
individualOne election-specific voting link
batchOne 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_at requires 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.ts
  • src/routes/admin.ts
  • src/index.ts
  • src/templates/vote-my-elections.tsx
  • src/utils/db.ts
  • migrations/011_batch_invites.sql
  • tests/unit/magic-link.test.ts
  • tests/integration/magic-link-security.test.ts
  • tests/integration/batch-invite-flow.test.ts