The Worker sends transactional email through the Resend HTTP API. It renders both HTML and plain-text bodies inside src/utils/email.ts. It does not use Cloudflare Queues; queued invitations are D1 rows processed by cron.

Delivery paths

flowchart LR
  ADMIN["Admin request"] --> INVITE["Invite / reminder / results route"]
  CRON["Cron · every minute"] --> SCHEDULED["Queued invites / reminders / results"]
  INVITE --> TEMPLATE["HTML + text template"]
  SCHEDULED --> TEMPLATE
  TEMPLATE --> RESEND["POST api.resend.com/emails"]
  RESEND --> STATUS[("D1 invite or election status")]
MessageRecipient linkTrigger
Individual invite/e/:id/vote?t={token}Admin invite route or queued-send cron
Batch invite/vote/my-elections?email={email}&token={magic_token}Admin invite route, bulk invite, or queued-send cron
ReminderSame link type as the election’s invite modeManual route or reminder cron
Results/e/:id/resultsManual route or close-detection cron

Batch result email can contain several elections for one recipient. Invite and result templates include the election title and optional description; invites can include close time in America/New_York.

Configuration

BindingUsed for
RESEND_API_KEYBearer token for api.resend.com
FROM_EMAILResend sender value
BASE_URLVote, election-list, and results links
REMINDER_DEFAULT_HOURS_BEFORE_CLOSEDefault reminder window; runtime default is 24
REMINDER_MAX_EMAILS_PER_RUNScheduled reminder cap; runtime default is 50

Invite, reminder, and results routes return 500 when the first three values are unavailable. Reminder settings are read dynamically even though the generated CloudflareBindings interface does not currently declare them.

Resend request

Each sender posts:

{
  "from": "IHNYC RC Vote <worker@ihnyc-rc.org>",
  "to": ["resident@example.org"],
  "subject": "[Action Required] Vote: Election title",
  "html": "<!DOCTYPE html>...",
  "text": "IHNYC Resident Council Voting..."
}

The results sender can also add cc.

The helper returns { "success": true } after any successful Resend response. It does not persist the Resend message id, delivery events, opens, clicks, or webhook state.

Individual invitations

POST /admin/elections/:id/invite creates or reuses an invitation token and processes normalized recipients sequentially with a 500-millisecond delay.

D1 invite stateMeaning
PENDINGRecord exists before an immediate send
QUEUEDHold until the election opens
SENTResend accepted the message
FAILEDThe send or surrounding operation failed

When an immediate individual send reports a rate-limit error, the route waits one second and retries once. If delivery still fails, it deletes the token so turnout does not include an undelivered credential, then retains or recreates a FAILED invite without a usable token.

The route stores plaintext tokens in invites.token_plaintext for resends. The admin resend route reuses that token and increments resend_count.

Batch invitations

An election with invite_mode: "batch" sends one message per email address. The message can list every non-closed election currently associated with that address.

The route reuses the newest unexpired magic-link record whose used_at is NULL; otherwise it creates a two-UUID token expiring in seven days. The underlying election invitations still have separate voting tokens.

Batch send failures update matching pending or queued invite records to FAILED. Unlike the individual path, the batch sender itself has no rate-limit retry.

See Magic-link invitations for the election-list route and its current enforcement gap.

Queued invitations

An invite is queued only when the request contains queue: true and the election is not yet open. The minute cron looks for elections that are open now but were not open one minute earlier.

For those elections:

  • individual mode sends each queued invitation separately;
  • batch mode groups just-opened invites by email and sends one current election list; and
  • each invite becomes SENT or FAILED.

The scheduled handler spaces outbound messages by 500 milliseconds.

Reminders

An election can set settings.reminder_hours_before_close.

ValueBehavior
Positive number or numeric stringUse that many hours
0 or falseDisable reminders
Missing or invalidUse REMINDER_DEFAULT_HOURS_BEFORE_CLOSE, then 24

Scheduled reminders include only open elections, unused invitation tokens, and invites not previously reminded. Batch-mode reminders group closing elections by recipient; individual mode sends one message per invite. The scheduled cap defaults to 50 successfully sent messages per run.

Manual routes can send reminders for an election or one invitation. force: true bypasses the time window and previous-reminder check, but it does not bypass the open-election or unused-token requirements.

Successful reminders write reminder_sent_at and increment reminder_count.

Results notifications

The scheduled handler finds closed elections with no results_emails_sent_at. It selects SENT and PENDING invite addresses, groups all newly available results by recipient, and sends one message to each recipient.

An election is marked complete only after every intended recipient’s message succeeds. An election with no eligible recipients is also marked complete so cron does not retry forever.

The manual POST /admin/elections/:id/results-email route can combine invite recipients with distribution lists. It sends one message with the first recipient in to and all remaining recipients in cc, then sets results_emails_sent_at when Resend accepts it.

Template handling

  • HTML interpolation escapes election titles, descriptions, URLs, and formatted dates.
  • Every message also has a plain-text version.
  • Branding image URLs and product wording are embedded in the source templates.
  • Tests can inject a sender function or replace global fetch; production uses the Resend sender.

Current constraints

  • Batch email copy says its magic link “can only be used once,” but the election-list route does not check used_at or mark the link used.
  • The manual results route exposes every address after the first in the message’s cc field.
  • Queued delivery only selects elections that opened during the previous minute. A cron gap longer than that can leave queued invites unsent.
  • Resend acceptance is the only recorded delivery signal; there is no provider message id or webhook reconciliation.
  • The source hardcodes external branding image URLs in every template.

Sources

Verified against ihnyc-rc-vote commit 2451aa1.

  • src/utils/email.ts
  • src/utils/reminders.ts
  • src/routes/admin.ts
  • src/index.ts
  • src/utils/db.ts
  • .dev.vars.example
  • migrations/003_invites.sql
  • migrations/005_add_token_plaintext.sql
  • migrations/010_add_queued_invite_status.sql
  • migrations/012_add_resend_count.sql
  • migrations/013_add_invite_reminder_tracking.sql