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)
- Select Batch Mode: When creating or managing an election, select “Batch” as the invite mode
- Send Invites: Enter email addresses (or select distribution lists) and send invites
- 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
Method 2: Bulk Invite Management (Recommended for Multiple Elections)
- Navigate to Bulk Invites: Go to Admin Dashboard → ”📧 Bulk Invites”
- Select Multiple Elections: Check the elections you want to send invites for
- Select Recipients: Enter email addresses and/or select distribution lists
- Choose Options: Select invite mode (batch/individual) and queue option
- Send: System processes all elections and recipients in one operation
- 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
- Receive Email: Voter receives one email with subject:
[Action Required] You have N election(s) to vote in - Click Magic Link: Email contains a single “Cast Your Vote(s)” button with a magic link
- Access My Elections Page: Magic link takes them to
/vote/my-elections?email={email}&token={magic_token} - 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)
- 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:
Magic Link Security
- 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
email_magic_links Table
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 emailidx_magic_links_token: For validating tokensidx_magic_links_expires: For cleanup queries
elections Table Addition
invite_mode TEXT DEFAULT 'individual' CHECK (invite_mode IN ('individual', 'batch'))API Endpoints
Magic Link Validation
Route: GET /vote/my-elections?email={email}&token={magic_token}
Validation Steps:
- Check email and token parameters are present
- Validate email format (regex)
- Look up magic link by token
- Verify token exists
- Check token hasn’t expired (
expires_at < Date.now()) - Check token hasn’t been used (
used_at IS NULL) - Verify email matches token’s email (case-insensitive)
- Mark token as used (atomic operation)
- Fetch all elections for email
- 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:
- Collect all emails (from direct input + distribution lists)
- 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:
- Validates all election IDs exist and are not closed
- Collects all emails (from direct input + distribution lists)
- Creates invites for all selected elections
- 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
- 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 linkgetMagicLinkByToken(token): Retrieves magic link by tokenmarkMagicLinkUsed(id): Marks link as used (atomic)getElectionsForEmail(email): Gets all elections with pending invitesgetPendingInvitesByEmail(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 electionPOST /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 linkgenerateBatchInviteEmailHTML(): Generates HTML email template
Templates:
src/templates/vote-my-elections.tsx:MyElectionsPage- Renders the elections list pagesrc/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 daysBase URL
The magic link URL uses the BASE_URL environment variable:
wrangler secret put BASE_URL
# Example: https://vote.ihnyc-rc.orgUsage Examples
Admin: Enable Batch Mode (Per-Election)
- Go to election management page
- In “Send Invites” section, select “Batch” from “Invite Mode” dropdown
- Enter email addresses or select distribution lists
- Click “Send Invites”
- System sends one email per recipient listing all their pending elections
Admin: Bulk Invite Management (Multiple Elections)
- Go to Admin Dashboard
- Click ”📧 Bulk Invites” button
- 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
- Select Recipients:
- Enter email addresses (one per line or comma-separated)
- Check distribution lists to include
- Can use both emails and distribution lists together
- Configure Options:
- Choose “Batch Mode” (recommended) or “Individual Mode”
- Check “Queue invites” if elections are upcoming
- Click “Send Invites”
- 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
Voter: Using Magic Link
- Receive email: “You have 3 election(s) to vote in”
- Click “Cast Your Vote(s)” button
- See “My Elections” page with:
- 2 open elections (with “Vote” buttons)
- 1 upcoming election (shows open date)
- Click “Vote” on an open election
- Cast vote using individual token
- Return to “My Elections” page to vote in other elections
Troubleshooting
Magic Link Already Used
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.
Magic Link Expired
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_linkstable - Adds
invite_modecolumn toelectionstable - 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 functionstests/integration/magic-link-security.test.ts: Security validation teststests/integration/batch-invite-flow.test.ts: End-to-end flow teststests/integration/batch-invite-edge-cases.test.ts: Edge case handling
Run tests:
npm test
npm run test:unit
npm run test:integration