Skip to content

CLI reference

Operator CLI for BulletinMail. Source: cli/src/index.ts.

Synopsis

bulletin <command> [options]

Commands shell out to wrangler d1 execute --remote --file=<tmp> to run parameterized SQL against the D1 database named bulletinmail (override via env var BULLETINMAIL_D1_NAME). Wrangler must be authenticated to the account that owns the D1 database.

Commands

create-tenant

Create a new tenant (church/org) and its first admin.

Options:

FlagRequiredDescription
--slug <slug>yesDNS-safe tenant slug. Validated by validateTenantSlug in @bulletinmail/shared.
--name <name>yesDisplay name shown to members.
--admin-email <email>yesEmail of the first admin. Lowercased before insert.

Inserts: one row into tenants, one row into admins. Tenant id and admin id are generated as ulids (with t_ and a_ prefixes).

Exit: 0 on success. Non-zero on validation failure or D1 error.

create-group

Create a new mailing list (group) within an existing tenant.

Options:

FlagRequiredDescription
--tenant <slug>yesSlug of an existing tenant.
--name <local>yesLocal-part of the list address (e.g. announcements). Must match /^[a-z][a-z0-9-]*[a-z0-9]$/.
--display <name>yesDisplay name shown to recipients.
--policy <policy>yesPosting policy. One of members, moderated, announce_only, open.
--reply-to <policy>noReply-To policy. One of list, sender. Default: list.
--prefix <prefix>noSubject prefix (e.g. [Announcements]).

Inserts: one row into groups with archive_visibility='members', max_message_size=10485760.

add-member

Add a member to a group.

Options:

FlagRequiredDescription
--tenant <slug>yesTenant slug.
--group <name>yesGroup local-part.
--email <email>yesMember email. Lowercased before insert.
--name <name>noDisplay name.
--role <role>noOne of member, moderator, sender_only. Default: member.

Inserts: one row into members with delivery_mode='each', status='active', bounce_count=0.

remove-member

Mark a member as unsubscribed. Does not delete; the row remains for audit and idempotent reactivation.

Options:

FlagRequiredDescription
--tenant <slug>yesTenant slug.
--group <name>yesGroup local-part.
--email <email>yesMember email.

Updates: members.status = 'unsubscribed' for the matched row.

list-groups

List groups for a tenant with active-member counts.

Options:

FlagRequiredDescription
--tenant <slug>yesTenant slug.

Output: raw wrangler d1 execute JSON for SELECT g.name, g.display_name, g.posting_policy, COUNT(members WHERE status='active') ....

Environment variables

VarDefaultDescription
BULLETINMAIL_D1_NAMEbulletinmailD1 database name passed to wrangler d1 execute.

Invocation

The CLI ships as a TypeScript file with a tsx shebang. From the repo root:

Terminal window
node --import tsx/esm cli/src/index.ts <command> [options]
# or
pnpm cli <command> [options]

SQL escaping

The CLI generates SQL string literals with single quotes doubled. Identifiers (table and column names) are never user-supplied at the SQL layer. There is no transaction wrapper; each command runs a single statement.