Overview

Magic Link Invite Mode (also called “Batch Mode”) is an alternative to individual email invites that consolidates multiple election invitations into a single email per recipient. Instead of receiving separate emails for each election, voters receive one email listing all their pending elections with a single-use “magic link” that grants access to a personalized “My Elections” page.

Benefits

  • Reduced Email Volume: One email per recipient instead of multiple emails
  • Better UX: Voters can see all their elections in one place
  • Rate Limit Management: Helps stay within Resend API rate limits (2 requests/second)
  • Consolidated View: Voters see all pending elections at once

How It Works

Admin Flow

There are two ways to send batch invites:

Method 1: Per-Election Invites (Traditional)

  1. Select Batch Mode: When creating or managing an election, select “Batch” as the invite mode
  2. Send Invites: Enter email addresses (or select distribution lists) and send invites
  3. System Processing:
    • For each email address, the system checks for other pending elections
    • If other pending elections exist, they’re included in the batch email
    • A magic link is generated or reused for the email address
    • One email is sent listing all pending elections
  1. Navigate to Bulk Invites: Go to Admin Dashboard → ”📧 Bulk Invites”
  2. Select Multiple Elections: Check the elections you want to send invites for
  3. Select Recipients: Enter email addresses and/or select distribution lists
  4. Choose Options: Select invite mode (batch/individual) and queue option
  5. Send: System processes all elections and recipients in one operation
  6. System Processing:
    • Creates invites for all selected elections
    • Groups elections by recipient email
    • Sends one batch email per recipient with all their elections
    • Uses existing magic links or creates new ones as needed

Benefits of Bulk Invite Management:

  • Send invites for multiple elections at once
  • No need to visit each election page individually
  • See all elections and recipients in one view
  • More efficient workflow for managing multiple elections

Voter Flow

  1. Receive Email: Voter receives one email with subject: [Action Required] You have N election(s) to vote in
  2. Click Magic Link: Email contains a single “Cast Your Vote(s)” button with a magic link
  3. Access My Elections Page: Magic link takes them to /vote/my-elections?email={email}&token={magic_token}
  4. View All Elections: Page shows all elections they’re invited to, grouped by status:
    • Open: Elections currently accepting votes (with “Vote” button)
    • Upcoming: Elections not yet open (shows when voting opens)
    • Closed: Elections that have closed (with “View Results” link)
  5. Cast Votes: Click “Vote” on an open election to go to the voting page with their individual token

Security Model

The system uses a hybrid security approach:

  • Single-Use: Magic links are marked as used after first access
  • Time-Limited: Expires after 7 days (configurable)
  • Email-Bound: Magic link is tied to a specific email address
  • Token Validation: System validates:
    • Token exists in database
    • Token hasn’t expired
    • Token hasn’t been used
    • Email parameter matches the token’s email

Individual Token Security

  • Still Required: Each election still requires its own unique token to cast a vote
  • Magic Link ≠ Vote Token: The magic link only grants access to view elections
  • Token in URL: Individual voting tokens are passed in the vote page URL (/e/{id}/vote?t={token})

Security Flow

Magic Link → My Elections Page → Individual Vote Page (with token)
   ↓              ↓                        ↓
Validates      Shows elections      Validates token
Single-use     (read-only)          Allows voting

Database Schema

CREATE TABLE email_magic_links (
    id TEXT PRIMARY KEY,
    email TEXT NOT NULL,
    magic_token TEXT NOT NULL UNIQUE,
    expires_at INTEGER NOT NULL,
    used_at INTEGER,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
);

Fields:

  • id: Unique identifier (UUID)
  • email: Email address (lowercased, indexed)
  • magic_token: Secure random token (two concatenated UUIDs)
  • expires_at: Expiration timestamp (milliseconds)
  • used_at: Timestamp when link was used (NULL if unused)
  • created_at: Creation timestamp

Indexes:

  • idx_magic_links_email: For looking up links by email
  • idx_magic_links_token: For validating tokens
  • idx_magic_links_expires: For cleanup queries

elections Table Addition

invite_mode TEXT DEFAULT 'individual' CHECK (invite_mode IN ('individual', 'batch'))

API Endpoints

Route: GET /vote/my-elections?email={email}&token={magic_token}

Validation Steps:

  1. Check email and token parameters are present
  2. Validate email format (regex)
  3. Look up magic link by token
  4. Verify token exists
  5. Check token hasn’t expired (expires_at < Date.now())
  6. Check token hasn’t been used (used_at IS NULL)
  7. Verify email matches token’s email (case-insensitive)
  8. Mark token as used (atomic operation)
  9. Fetch all elections for email
  10. Render “My Elections” page

Error Responses:

  • Missing parameters → “Invalid Link”
  • Invalid email format → “Invalid Link”
  • Token not found → “Invalid Link”
  • Token expired → “Link Expired”
  • Token already used → “Link Already Used”
  • Email mismatch → “Invalid Link”

Admin Invite Endpoints

Per-Election Invite Endpoint

Route: POST /admin/elections/:id/invite

Parameters:

{
  "emails": ["voter1@example.com", "voter2@example.com"],
  "invite_mode": "batch",
  "distribution_list_ids": ["list_id_1"],
  "queue": false
}

Batch Mode Behavior:

  1. Collect all emails (from direct input + distribution lists)
  2. For each email:
    • Get all pending elections (status: PENDING, QUEUED, or SENT)
    • Filter to active elections (not closed)
    • Generate or retrieve existing magic link
    • Create/update invite record for current election
    • Send batch email with all elections listed

Bulk Invite Endpoint

Route: POST /admin/bulk-invites

Parameters:

{
  "election_ids": ["elec_123", "elec_456", "elec_789"],
  "emails": ["voter1@example.com", "voter2@example.com"],
  "distribution_list_ids": ["list_id_1"],
  "invite_mode": "batch",
  "queue": false
}

Batch Mode Behavior:

  1. Validates all election IDs exist and are not closed
  2. Collects all emails (from direct input + distribution lists)
  3. Creates invites for all selected elections
  4. For each email address:
    • Gets all elections they’re invited to (from selected elections)
    • Generates or retrieves existing magic link
    • Sends one batch email listing all their elections
  5. Updates all invites to SENT or FAILED based on email result

Response:

{
  "success": true,
  "mode": "batch",
  "queued": false,
  "summary": {
    "total": 10,
    "sent": 9,
    "failed": 1,
    "queued": 0
  },
  "results": [
    { "email": "voter1@example.com", "success": true },
    { "email": "voter2@example.com", "success": false, "error": "..." }
  ]
}

Use Cases:

  • Sending invites for multiple elections to the same group of people
  • Managing invites across multiple elections efficiently
  • Reducing the number of operations needed to send invites

Email Template

Batch Invite Email

Subject: [Action Required] You have {count} election(s) to vote in

Content:

  • Lists all pending elections with:
    • Election title
    • Description (if available)
    • Close date (formatted)
    • Status badge (OPEN/UPCOMING)
  • Single CTA button: “Cast Your Vote(s)” → magic link URL
  • Warning about single-use magic link

Magic Link URL Format:

{BASE_URL}/vote/my-elections?email={email}&token={magic_token}

Code Reference

Key Functions

Database Helpers (src/utils/db.ts):

  • createMagicLink(email, expiresAt): Creates a new magic link
  • getMagicLinkByToken(token): Retrieves magic link by token
  • markMagicLinkUsed(id): Marks link as used (atomic)
  • getElectionsForEmail(email): Gets all elections with pending invites
  • getPendingInvitesByEmail(email): Gets all pending invites for email

Routes (src/routes/vote.ts):

  • GET /vote/my-elections: Validates magic link and shows elections

Admin Routes (src/routes/admin.ts):

  • POST /admin/elections/:id/invite: Send invites for a single election
  • POST /admin/bulk-invites: Send invites for multiple elections (bulk management)

Admin UI Routes (src/routes/admin-ui.ts):

  • GET /admin/bulk-invites: Bulk invite management page

Email (src/utils/email.ts):

  • sendBatchInviteEmail(): Sends batch invite email with magic link
  • generateBatchInviteEmailHTML(): Generates HTML email template

Templates:

  • src/templates/vote-my-elections.tsx: MyElectionsPage - Renders the elections list page
  • src/templates/admin-bulk-invites.tsx: AdminBulkInvitesPage - Bulk invite management interface

Configuration

Expiration Time

Magic links expire after 7 days by default. This is configured in the invite sending logic:

const expiresAt = Date.now() + (7 * 24 * 60 * 60 * 1000); // 7 days

Base URL

The magic link URL uses the BASE_URL environment variable:

wrangler secret put BASE_URL
# Example: https://vote.ihnyc-rc.org

Usage Examples

Admin: Enable Batch Mode (Per-Election)

  1. Go to election management page
  2. In “Send Invites” section, select “Batch” from “Invite Mode” dropdown
  3. Enter email addresses or select distribution lists
  4. Click “Send Invites”
  5. System sends one email per recipient listing all their pending elections

Admin: Bulk Invite Management (Multiple Elections)

  1. Go to Admin Dashboard
  2. Click ”📧 Bulk Invites” button
  3. Select Elections:
    • Check boxes for elections you want to send invites for
    • Use “Select All”, “Deselect All”, or “Select Open Only” helpers
    • Closed elections are automatically disabled
  4. Select Recipients:
    • Enter email addresses (one per line or comma-separated)
    • Check distribution lists to include
    • Can use both emails and distribution lists together
  5. Configure Options:
    • Choose “Batch Mode” (recommended) or “Individual Mode”
    • Check “Queue invites” if elections are upcoming
  6. Click “Send Invites”
  7. System processes all elections and sends batch emails

Example Scenario:

  • You have 3 elections: “Board President”, “Treasurer”, “Secretary”
  • You want to invite 50 people to all 3 elections
  • Using Bulk Invites:
    • Select all 3 elections
    • Enter 50 email addresses (or select a distribution list)
    • Choose “Batch Mode”
    • Click “Send Invites”
    • Result: Each person receives ONE email with all 3 elections listed
  1. Receive email: “You have 3 election(s) to vote in”
  2. Click “Cast Your Vote(s)” button
  3. See “My Elections” page with:
    • 2 open elections (with “Vote” buttons)
    • 1 upcoming election (shows open date)
  4. Click “Vote” on an open election
  5. Cast vote using individual token
  6. Return to “My Elections” page to vote in other elections

Troubleshooting

Error: “Link Already Used”

Cause: Magic links are single-use. Once accessed, they cannot be reused.

Solution: Admin must send a new invite, which will generate a new magic link.

Error: “Link Expired”

Cause: Magic link expired after 7 days.

Solution: Admin must send a new invite to generate a new magic link.

No Elections Showing

Possible Causes:

  • All elections have been voted on
  • All elections are closed
  • Invites were not sent in batch mode
  • Email address doesn’t match

Solution: Verify invites were sent and check election status.

Migration

The magic link system was added in migration 011_batch_invites.sql. This migration:

  • Creates email_magic_links table
  • Adds invite_mode column to elections table
  • Preserves all existing data

See migrations for migration details.

Testing

Comprehensive tests are available in:

  • tests/unit/magic-link.test.ts: Unit tests for magic link functions
  • tests/integration/magic-link-security.test.ts: Security validation tests
  • tests/integration/batch-invite-flow.test.ts: End-to-end flow tests
  • tests/integration/batch-invite-edge-cases.test.ts: Edge case handling

Run tests:

npm test
npm run test:unit
npm run test:integration