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")]
| Message | Recipient link | Trigger |
|---|---|---|
| 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 |
| Reminder | Same link type as the election’s invite mode | Manual route or reminder cron |
| Results | /e/:id/results | Manual 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
| Binding | Used for |
|---|---|
RESEND_API_KEY | Bearer token for api.resend.com |
FROM_EMAIL | Resend sender value |
BASE_URL | Vote, election-list, and results links |
REMINDER_DEFAULT_HOURS_BEFORE_CLOSE | Default reminder window; runtime default is 24 |
REMINDER_MAX_EMAILS_PER_RUN | Scheduled 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 state | Meaning |
|---|---|
PENDING | Record exists before an immediate send |
QUEUED | Hold until the election opens |
SENT | Resend accepted the message |
FAILED | The 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
SENTorFAILED.
The scheduled handler spaces outbound messages by 500 milliseconds.
Reminders
An election can set settings.reminder_hours_before_close.
| Value | Behavior |
|---|---|
| Positive number or numeric string | Use that many hours |
0 or false | Disable reminders |
| Missing or invalid | Use 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_ator mark the link used. - The manual results route exposes every address after the first in the message’s
ccfield. - 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.tssrc/utils/reminders.tssrc/routes/admin.tssrc/index.tssrc/utils/db.ts.dev.vars.examplemigrations/003_invites.sqlmigrations/005_add_token_plaintext.sqlmigrations/010_add_queued_invite_status.sqlmigrations/012_add_resend_count.sqlmigrations/013_add_invite_reminder_tracking.sql