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_at is 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* and generateBatchInviteEmail*) with reminder subjects.

Reminder Configuration

Per-election setting (preferred)

In elections.settings_json:

  • reminder_hours_before_close: number of hours before close_at when reminders become eligible
    • Set to 0 to disable reminders for that election

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 key
  • FROM_EMAIL: Verified sender email address
  • BASE_URL: Base URL for voting links

Setup:

wrangler secret put RESEND_API_KEY
wrangler secret put FROM_EMAIL
wrangler secret put BASE_URL

Email Verification

The FROM_EMAIL must be verified in Resend:

  1. Go to Resend Dashboard → Domains
  2. Add and verify your domain
  3. 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 HTML
  • generateInviteEmailText(): Individual invite text
  • generateBatchInviteEmailHTML(): Batch invite HTML
  • generateBatchInviteEmailText(): Batch invite text
  • generateResultsEmailHTML(): Results email HTML
  • generateResultsEmailText(): 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

  1. Batch Sending: Use CC for results emails
  2. Magic Links: One email per recipient (batch mode)
  3. Delays: 500ms delay between emails when sending queued invites
  4. Error Handling: Graceful handling of rate limit errors

Best Practices

  1. Use Batch Mode: Reduces email volume
  2. Stagger Sends: Add delays for large batches
  3. Monitor Limits: Watch for rate limit errors
  4. 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:

  1. Go to election management
  2. View failed invites
  3. Click “Resend” next to failed invite

Via API:

POST /admin/elections/:id/invites/:inviteId/resend

Email 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:

  1. Admin can queue invites
  2. Invites stored with status “QUEUED”
  3. Cron job checks for elections that just opened
  4. Sends queued invites automatically

Cron Job

Runs every minute:

  1. Finds elections that just opened (within last minute)
  2. Gets queued invites for those elections
  3. Sends invites with 500ms delay between each
  4. 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_at is null
  • Sent to all recipients with status “SENT” or “PENDING”

Manual Sending

Via Admin UI:

  1. Go to election management
  2. Click “Send Results Email”

Via API:

POST /admin/elections/:id/results-email

Testing

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:

  1. Generating HTML/text
  2. Asserting content
  3. Checking for required elements

Troubleshooting

Emails Not Sending

Check:

  1. RESEND_API_KEY is set correctly
  2. FROM_EMAIL is verified in Resend
  3. BASE_URL is set correctly
  4. No rate limit errors in logs

Rate Limit Errors

Solutions:

  1. Use batch mode (magic links)
  2. Add delays between sends
  3. Upgrade Resend plan
  4. Stagger large sends

Invalid Email Errors

Check:

  1. Email format is valid
  2. Domain is verified in Resend
  3. No typos in email addresses

See Also