A distribution list is a named, reusable set of email addresses. Lists do not grant voting access by themselves: an invite action expands current list membership and creates an election invitation and token for each resulting recipient.

Data flow

flowchart LR
  DIRECT["Direct email input"] --> MERGE["Normalize and deduplicate"]
  LISTS["Selected distribution lists"] --> EXPAND["Read current members"]
  EXPAND --> MERGE
  MERGE --> INVITES["Create or update election invites"]
  INVITES --> EMAIL["Send or queue email"]

Membership is evaluated when the invite or results-email request runs. The service does not store a snapshot linking a distribution list to an election.

Storage

distribution_lists stores:

ColumnMeaning
idlist_ followed by a UUID
nameRequired display name
descriptionOptional text
created_atCreation time
updated_atLast name or description update

distribution_list_emails stores an id, list_id, normalized email, and creation time. A unique constraint prevents the same lowercase address from appearing twice in one list. Deleting a list cascades to its membership rows.

Admin routes

All routes use the admin authentication boundary.

MethodRouteResult
GET/admin/distribution-listsLists with email_count
GET/admin/distribution-lists/:idOne list and its member rows
POST/admin/distribution-listsCreate a list
PUT/admin/distribution-lists/:idReplace name and description
DELETE/admin/distribution-lists/:idDelete the list and memberships
POST/admin/distribution-lists/:id/emailsAdd an array of addresses
DELETE/admin/distribution-lists/:id/emails/:emailIdRemove one membership row

Create:

{
  "name": "Resident delegates",
  "description": "Current delegate mailing list"
}

Add members:

{
  "emails": [
    "resident.one@example.org",
    "resident.two@example.org"
  ]
}

The browser interface at /admin/distribution-lists uses these JSON routes.

Normalization

On membership insert, the helper:

  1. trims each string;
  2. converts it to lowercase;
  3. skips values that are empty or do not contain @; and
  4. attempts each remaining insert.

Duplicate inserts are ignored by the helper. The response reports only rows that were added:

{
  "success": true,
  "added": 1,
  "emails": [
    {
      "id": "1e9d96c4-4f66-4984-92b8-c2d342ae50cb",
      "email": "resident.one@example.org",
      "created_at": 1785081600000
    }
  ]
}

Invitation expansion

POST /admin/elections/:id/invite accepts direct emails, distribution_list_ids, or both. It loads every selected list, appends its current addresses, then trims, lowercases, and deduplicates the combined set.

{
  "emails": ["chair@example.org"],
  "distribution_list_ids": ["list_123"],
  "invite_mode": "individual",
  "queue": false
}

A list id that cannot be loaded contributes no addresses and does not produce its own error. The request fails only when the final recipient set is empty.

POST /admin/bulk-invites uses the same direct-email and list expansion for multiple selected elections.

Results email

POST /admin/elections/:id/results-email starts with invite records in SENT or PENDING state unless use_invited_recipients is false. It can add current members from distribution_list_ids, then performs the same case-insensitive deduplication.

The manual route sends one Resend message: the first recipient is to, and every remaining recipient is cc. See Email system for scheduled result delivery, which uses separate recipient messages.

Lifecycle effects

  • Editing membership changes future expansions only.
  • Removing an address does not revoke existing election invites or tokens.
  • Deleting a list does not delete invitations already created from it.
  • List names are not required to be unique.
  • Create, update, delete, add-member, and remove-member calls write admin.access audit events.

Current constraints

  • Email validation is only an @ presence check, not full address validation.
  • The insert helper catches every D1 insert error as if it were a duplicate, so an unrelated insert failure can be silently omitted from added.
  • Missing list ids are silently ignored by invite and results-email expansion.
  • Removing an unknown emailId still returns success because the route does not inspect the deleted-row count.

Sources

Verified against ihnyc-rc-vote commit 2451aa1.

  • src/routes/admin.ts
  • src/routes/admin-ui.ts
  • src/templates/admin-distribution-lists.tsx
  • src/utils/db.ts
  • migrations/009_distribution_lists.sql