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:
| Column | Meaning |
|---|---|
id | list_ followed by a UUID |
name | Required display name |
description | Optional text |
created_at | Creation time |
updated_at | Last 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.
| Method | Route | Result |
|---|---|---|
GET | /admin/distribution-lists | Lists with email_count |
GET | /admin/distribution-lists/:id | One list and its member rows |
POST | /admin/distribution-lists | Create a list |
PUT | /admin/distribution-lists/:id | Replace name and description |
DELETE | /admin/distribution-lists/:id | Delete the list and memberships |
POST | /admin/distribution-lists/:id/emails | Add an array of addresses |
DELETE | /admin/distribution-lists/:id/emails/:emailId | Remove 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:
- trims each string;
- converts it to lowercase;
- skips values that are empty or do not contain
@; and - 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.accessaudit 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
emailIdstill returns success because the route does not inspect the deleted-row count.
Sources
Verified against ihnyc-rc-vote commit 2451aa1.
src/routes/admin.tssrc/routes/admin-ui.tssrc/templates/admin-distribution-lists.tsxsrc/utils/db.tsmigrations/009_distribution_lists.sql