Overview
The email system handles all outbound emails for the voting system, including vote invites, batch invites, and results notifications. It uses Resend as the email service provider and includes HTML and plain-text templates.
Email Types
1. Vote Invite (Individual)
When Sent: When admin sends individual invites
Subject: [Action Required] Vote: {Election Title}
Content:
- Election title
- Description (if available)
- Close date
- Voting link with token
- IHNYC RC branding
Template: generateInviteEmailHTML() / generateInviteEmailText()
2. Batch Invite
When Sent: When admin sends batch invites (magic link mode)
Subject: [Action Required] You have {count} election(s) to vote in
Content:
- List of all pending elections
- Each election shows: title, description, close date, status
- Single magic link to “My Elections” page
- Warning about single-use link
Template: generateBatchInviteEmailHTML() / generateBatchInviteEmailText()
3. Results Notification
When Sent: When election closes (via cron job)
Subject: Voting Results: {Election Title}
Content:
- Election title
- Description (if available)
- Results link
- IHNYC RC branding
Template: generateResultsEmailHTML() / generateResultsEmailText()
Note: Can include CC recipients for batch sending.
4. Reminder Emails (Close-At Approaching)
When Sent:
- Automatically by the cron job when an election is open and its
close_atis within the configured reminder window - Manually by an admin endpoint (see below)
Eligibility:
- Only sent to invitees whose token has not been used (
tokens.used_at IS NULL) - Only sent once per invite by default (
invites.reminder_sent_at IS NULL)
Subjects:
- Individual-mode election:
[Reminder] Vote: {Election Title} - Batch-mode election:
[Reminder] You have {count} election(s) closing soon
Templates:
- Reuses the existing invite templates (
generateInviteEmail*andgenerateBatchInviteEmail*) with reminder subjects.
Reminder Configuration
Per-election setting (preferred)
In elections.settings_json:
reminder_hours_before_close: number of hours beforeclose_atwhen reminders become eligible- Set to
0to disable reminders for that election
- Set to
Global defaults (optional)
Environment variables:
REMINDER_DEFAULT_HOURS_BEFORE_CLOSE(default:24)REMINDER_MAX_EMAILS_PER_RUN(default:50) — cron safety cap
Admin Endpoints (Manual Reminders)
POST /admin/elections/:id/reminders/send- Sends reminders to all eligible recipients for that election
- Body:
{ "force": true }to bypass the time window / “already reminded” checks (still never sends to used tokens)
POST /admin/elections/:id/invites/:inviteId/remind- Sends a reminder for one invite
- Body:
{ "force": true }supported
Resend Integration
API
The system uses Resend’s REST API:
Endpoint: https://api.resend.com/emails
Authentication: Bearer token (API key)
Rate Limit: 2 requests per second
Configuration
Required Secrets:
RESEND_API_KEY: Your Resend API keyFROM_EMAIL: Verified sender email addressBASE_URL: Base URL for voting links
Setup:
wrangler secret put RESEND_API_KEY
wrangler secret put FROM_EMAIL
wrangler secret put BASE_URLEmail Verification
The FROM_EMAIL must be verified in Resend:
- Go to Resend Dashboard → Domains
- Add and verify your domain
- Or use Resend’s test domain for development
Email Templates
HTML Templates
All emails include:
- Responsive Design: Works on mobile and desktop
- IHNYC RC Branding: Logo and colors
- Clear CTAs: Prominent action buttons
- Accessibility: Proper HTML structure
Plain-Text Templates
Plain-text versions are included for:
- Email clients that don’t support HTML
- Accessibility
- Spam filtering
Template Functions
Located in src/utils/email.ts:
generateInviteEmailHTML(): Individual invite HTMLgenerateInviteEmailText(): Individual invite textgenerateBatchInviteEmailHTML(): Batch invite HTMLgenerateBatchInviteEmailText(): Batch invite textgenerateResultsEmailHTML(): Results email HTMLgenerateResultsEmailText(): Results email text
Sending Emails
Individual Invite
await sendVoteInviteEmail({
to: "voter@example.com",
from: env.FROM_EMAIL,
subject: "[Action Required] Vote: Election Title",
electionTitle: "Election Title",
voteUrl: "https://vote.example.com/e/elec_123/vote?t=token",
apiKey: env.RESEND_API_KEY,
description: "Election description",
closeAt: 1704067200000,
});Batch Invite
await sendBatchVoteInviteEmail({
to: "voter@example.com",
from: env.FROM_EMAIL,
subject: "[Action Required] You have 3 election(s) to vote in",
apiKey: env.RESEND_API_KEY,
elections: [
{
title: "Election 1",
description: "Description",
closeAt: 1704067200000,
status: "open",
},
],
magicLinkUrl: "https://vote.example.com/vote/my-elections?email=...&token=...",
});Results Notification
await sendResultsNotificationEmail({
to: "voter@example.com",
from: env.FROM_EMAIL,
subject: "Voting Results: Election Title",
electionTitle: "Election Title",
resultsUrl: "https://vote.example.com/e/elec_123/results",
apiKey: env.RESEND_API_KEY,
description: "Election description",
cc: ["voter2@example.com"], // Optional CC for batch sending
});Custom Senders
For Testing
You can provide a custom sender function for testing:
const mockSender = async (params) => {
console.log("Would send email:", params);
return { success: true };
};
await sendVoteInviteEmail(params, mockSender);Sender Interface
type EmailSender = (params: EmailParams) => Promise<{
success: boolean;
error?: string;
}>;Error Handling
Rate Limiting
Resend allows 2 requests per second. The system:
- Detects rate limit errors (429 status)
- Returns descriptive error messages
- Logs rate limit violations
Error Response:
{
"success": false,
"error": "Rate limit exceeded: ... Resend allows 2 requests per second."
}Other Errors
Common errors:
- Invalid API Key: 401 Unauthorized
- Unverified Domain: Email rejected
- Invalid Email: Email format error
- Network Error: Connection issues
Error Response Format
{
success: boolean;
error?: string; // Error message if success is false
}Rate Limit Management
Resend Limits
- Free Tier: 100 emails/day, 2 requests/second
- Paid Tiers: Higher limits
System Strategies
- Batch Sending: Use CC for results emails
- Magic Links: One email per recipient (batch mode)
- Delays: 500ms delay between emails when sending queued invites
- Error Handling: Graceful handling of rate limit errors
Best Practices
- Use Batch Mode: Reduces email volume
- Stagger Sends: Add delays for large batches
- Monitor Limits: Watch for rate limit errors
- Upgrade Plan: Consider paid Resend plan for high volume
Email Tracking
Invite Status
Invites are tracked in the invites table:
- PENDING: Created but not sent
- SENT: Successfully sent
- FAILED: Failed to send
- QUEUED: Queued for when election opens
Failed Invites
Failed invites:
- Status set to “FAILED”
- Error message stored in
last_error - Can be resent via admin UI
Resending Invites
Via Admin UI:
- Go to election management
- View failed invites
- Click “Resend” next to failed invite
Via API:
POST /admin/elections/:id/invites/:inviteId/resendEmail Content
Security
- No Tokens in Logs: Tokens never logged
- HTTPS Links: All links use HTTPS
- Single-Use Links: Magic links are single-use
- Token in URL: Individual tokens in URL parameters
Formatting
- Dates: Formatted in America/New_York timezone
- HTML Escaping: All user content is escaped
- Responsive: Works on all devices
- Accessible: Proper HTML structure
Branding
- Logo: IHNYC RC logo included
- Colors: Consistent color scheme
- Footer: Standard footer with system name
Queued Invites
How It Works
When an election is not yet open:
- Admin can queue invites
- Invites stored with status “QUEUED”
- Cron job checks for elections that just opened
- Sends queued invites automatically
Cron Job
Runs every minute:
- Finds elections that just opened (within last minute)
- Gets queued invites for those elections
- Sends invites with 500ms delay between each
- Updates status to “SENT” or “FAILED”
Benefits
- Send invites in advance
- Automatic sending when election opens
- No manual intervention needed
Results Email Batching
CC Strategy
Results emails use CC to batch recipients:
- Primary recipient in “To” field
- Other recipients in “CC” field
- Saves on rate limits (one email instead of many)
When Sent
- Automatically when election closes (via cron)
- Only if
results_emails_sent_atis null - Sent to all recipients with status “SENT” or “PENDING”
Manual Sending
Via Admin UI:
- Go to election management
- Click “Send Results Email”
Via API:
POST /admin/elections/:id/results-emailTesting
Mock Sender
For tests, use a mock sender:
const sentEmails: EmailParams[] = [];
const mockSender = async (params: EmailParams) => {
sentEmails.push(params);
return { success: true };
};
await sendVoteInviteEmail(params, mockSender);
expect(sentEmails).toHaveLength(1);Test Templates
Templates can be tested by:
- Generating HTML/text
- Asserting content
- Checking for required elements
Troubleshooting
Emails Not Sending
Check:
RESEND_API_KEYis set correctlyFROM_EMAILis verified in ResendBASE_URLis set correctly- No rate limit errors in logs
Rate Limit Errors
Solutions:
- Use batch mode (magic links)
- Add delays between sends
- Upgrade Resend plan
- Stagger large sends
Invalid Email Errors
Check:
- Email format is valid
- Domain is verified in Resend
- No typos in email addresses
See Also
- magic-link-invites - Batch invite system
- distribution-lists - Bulk email management
- ops - Environment variable setup