Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,38 @@ PIXEL_SIGNING_SECRET=
# Generate this one with: openssl rand -base64 32
ESP_CREDENTIALS_ENCRYPTION_KEY=

# The initial account and its default team are created by the one-shot init
# service. Its API key is shown exactly once in `docker compose logs init`.
SUPER_ADMIN_EMAIL=admin@example.com
# The one-shot init service finds or creates this initial organization owner.
# This selects an ordinary organization owner; it does not grant global admin.
BOOTSTRAP_ORGANIZATION_OWNER_EMAIL=admin@example.com

# Optional headless integration keys. Generate distinct sl_org_live_... keys
# and keep both values in this server-side .env file; never expose them to a
# browser or mobile client.
BOOTSTRAP_DELIVERY_SETUP_API_KEY=
BOOTSTRAP_TEAM_PROVISIONING_API_KEY=

# Explicit billing mode. Use oss for self-hosted installs. Cloud requires the
# provider/catalog variables documented in apps/api/.env.example.
SENDLIT_DEPLOYMENT_MODE=oss
# Amounts are integer minor units (cents for USD). Raising a price requires a
# new provider product ID and a higher BILLING_CATALOG_REVISION. Paid amounts
# are never source constants. See apps/api/.env.example.

# Optional cloud fair-use controls (defaults match the published policy).
# BILLING_FAIR_USE_MIN_ACCEPTED=500
# BILLING_FAIR_USE_BOUNCE_WARN_BPS=200
# BILLING_FAIR_USE_COMPLAINT_WARN_BPS=5
# BILLING_FAIR_USE_BOUNCE_PAUSE_BPS=500
# BILLING_FAIR_USE_COMPLAINT_PAUSE_BPS=10
# BILLING_FAIR_USE_COMPLAINT_STOP_BPS=30
# BILLING_FAIR_USE_COMPLAINT_STOP_ABSOLUTE=10
# BILLING_FAIR_USE_TRANSACTIONAL_DAILY_LIMIT=100
# BILLING_FAIR_USE_MINIMUM_HOLD_HOURS=72
# BILLING_FAIR_USE_RECOVERY_CLEAN_DAYS=7
# BILLING_RAMP_DAYS_0_2_LIMIT=200
# BILLING_RAMP_DAYS_3_6_LIMIT=1000
# BILLING_RAMP_DAYS_7_13_LIMIT=10000
# BILLING_TEST_VOLUME_THRESHOLD=100

# Public origins. For a local installation, keep these defaults. For a public
# deployment, use the externally reachable HTTPS origins and set PROTOCOL=https.
Expand All @@ -35,3 +64,10 @@ ENABLE_TRUST_PROXY=false
MEDIALIT_APIKEY=
MEDIALIT_SERVER=https://api.medialit.cloud
MAX_UPLOAD_SIZE=10485760

# Optional: PostHog for the API (errors/events/logs) and the web dashboard
# (identify + session recording). Unset disables telemetry. The web app reads
# these at container start — they are not compiled into the Next.js image.
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
DEPLOY_ENV=
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
- Don't duplicate stuff over and over. Re-use existing code and libraries.
- While making changes to the `apps/api` directory, make sure the REST API documentation and MCP server are updated as well.
- For UI components, use shadcn/ui exclusively. Always use Shadcn CLI for installing components. Never hand roll standard Shadcn components. Prefer shadcn/ui components over browser-native components.
- When dealing with a large change, work through it in layers: money-path correctness first, then enforcement, then dashboard/self-serve, then ops and docs.
- If you are a Grok model, make sure you run the linter and tests before declaring any task done.

## Architecture Tips

Expand Down
24 changes: 14 additions & 10 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,15 @@ Phase 5 — **done**:
consumer's own tenants may share an owner email (which would otherwise
incorrectly merge them into one team). Provisioning creates no account
or membership; the organization owns the resulting team.
- `src/bootstrap.ts`: a _separate_, boot-time-only convenience directly
ported from MediaLit's `createAdminUser()` — if `SUPER_ADMIN_EMAIL` is
set and no account exists for it yet, creates one (with its default team
- key) and logs the key once. Useful for local dev/self-hosting a single
instance; not a substitute for `/provisioning/teams`, which a multi-tenant
consumer needs in order to provision many teams over the running lifetime
of the app, not just once at boot.
- `src/bootstrap.ts`: a _separate_, boot-time-only convenience — when
`BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` is set, it finds or creates that
ordinary initial owner, ensures their default organization/team, and
registers the configured delivery and team provisioning keys from the
environment. It stores only hashes and never logs secrets. This is
useful for local/self-hosted
installs, but is not a substitute for `/provisioning/teams`, which a
multi-tenant consumer needs to provision many teams over the running
lifetime of the app.
- `apps/web`: a `/dashboard/teams` page (list teams, create new ones,
switch — a plain form POST to `/api/team/switch` sets a
`sendlit_team_id` cookie the BFF proxy forwards as `X-Sendlit-Team-Id` —
Expand All @@ -253,9 +255,11 @@ Phase 5 — **done**:
contacts, sending identity, and quota, regardless of how many people or
integration keys touch it.

Validated end-to-end against a live Postgres + Redis + Mailpit stack: booted
with `SUPER_ADMIN_EMAIL` set and confirmed the account/team/key were created
and logged; created a contact via that key; provisioned a second team via
The original organization implementation was validated end-to-end against a
live Postgres + Redis + Mailpit stack: booted with
`BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` set and confirmed the
organization-owner/team/key were created; created a contact via that key;
provisioned a second team via
`POST /provisioning/teams` (simulating a CourseLit tenant) and created a
contact with the _same_ email address under it — confirmed both contacts
exist independently, one per team, with no collision; confirmed re-provisioning
Expand Down
100 changes: 86 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,88 @@ analytics, bounce handling and multi-user accounts are still on the roadmap.
- `packages/email-blocks` — headless composing blocks for broadcasts/
sequences/templates (`@sendlit/email-blocks`), used by `apps/web`.

## Running everything locally

1. Start Postgres and Redis (e.g. via Docker).
2. `apps/api`: copy `.env.example` to `.env`, fill in the values, then
`pnpm --filter @sendlit/api db:push` and `pnpm --filter @sendlit/api dev`.
3. `apps/web`: copy `.env.example` to `.env.local` (`API_URL` pointing at the
API above), then `pnpm --filter @sendlit/web dev`.
4. Build the two shared packages at least once so `apps/web` has something to
## Local development

Start the local dependencies with the dedicated Compose file, then run the API
and web app on the host:

```sh
docker compose -f docker-compose.local.yml up -d
docker compose -f docker-compose.local.yml ps
```

The local stack provides Postgres on `localhost:5434`, Redis on
`localhost:6380`, and Mailpit SMTP/UI on `localhost:1027`/`localhost:8027`.
Those defaults avoid collisions with the CourseLit local stack (Postgres
`5432`, Mailpit `1026`/`8026`) and the FrontLit local stack (Postgres `5433`,
Redis `6379`, Mailpit `1025`/`8025`). You can override the SendLit host ports
with `SENDLIT_LOCAL_POSTGRES_PORT`, `SENDLIT_LOCAL_REDIS_PORT`,
`SENDLIT_LOCAL_MAILPIT_SMTP_PORT`, and `SENDLIT_LOCAL_MAILPIT_HTTP_PORT` in
the shell or root `.env`; if you override them, update the matching database,
Redis, and SMTP ports in `apps/api/.env` too.

The `apps/api/.env.example` database, Redis, and platform SMTP settings match
these local services. Mailpit captures platform emails such as sign-in OTPs;
campaign and ESP test emails use the ESP configured in SendLit. When testing an
SMTP ESP against local Mailpit from the host-run API, use `127.0.0.1:1027`.
Open the Mailpit UI at <http://localhost:8027>.

Then push the development schema and start the apps as described below. To stop
the dependencies without deleting local database/queue data:

```sh
docker compose -f docker-compose.local.yml down
```

To intentionally delete the local Postgres and Redis data as well, use
`docker compose -f docker-compose.local.yml down -v`.

Then:

1. `apps/api`: copy `.env.example` to `.env` and fill in the values, then
`pnpm --filter @sendlit/api db:push`.
2. `apps/web`: copy `.env.example` to `.env.local` (`API_URL` pointing at the
API above).
3. Build the two shared packages at least once so `apps/web` has something to
import: `pnpm --filter @sendlit/email-editor build && pnpm --filter @sendlit/email-blocks build`
(re-run, or use their `dev` scripts, after changing either package).
4. From the repo root, start the apps you need:

```sh
pnpm dev:api
pnpm dev:web
pnpm dev:docs
```

## Operator billing CLI

Cloud billing recovery is a CLI, not the dashboard. From the repo root it
loads `apps/api/.env` and talks to that API's database:

```sh
pnpm --filter @sendlit/api billing catalog-status
pnpm --filter @sendlit/api billing catalog-verify
```

Run it with no arguments for the full list. Subcommands:

- `catalog-status` / `catalog-verify` / `catalog-abandon <revision> --reason <text>`
- `reconcile-org <organization_public_id>`
- `webhook-retry <provider_event_id>` / `webhook-inspect <provider_event_id>`
- `set-override <organization_public_id> --teams <n>|none --contacts <n>|none --reason <text>`
- `reputation-apply <team_public_id> <warned|marketing_paused|all_paused> --operator <user_id> --reason <text>`
- `reputation-release <team_public_id> --operator <user_id> --reason <text>`
- `cancel-subscription <organization_public_id> --reason <text>`

OSS mode has nothing to verify. A new `BILLING_CATALOG_REVISION` is recorded on
API startup; `catalog-verify` checks it against Dodo and activates it.

## Self-hosting with Docker Compose

The root Compose stack runs PostgreSQL, Redis, the API, and the web dashboard.
It also uses a one-shot `init` service to apply database migrations and create
the first account, its default team, and a team-scoped API key.
or find the initial organization owner, their default team, and any configured
organization API keys.

```sh
cp .env.example .env
Expand All @@ -59,11 +125,17 @@ docker compose up --build -d
docker compose logs init
```

Set `SUPER_ADMIN_EMAIL` before the first start. The `init` logs contain the
initial API key exactly once; save it in a password manager and use it as the
`x-sendlit-apikey` header. If it is lost, create a replacement in the dashboard
or through the authenticated API. Open the dashboard at `WEB_CLIENT` (by
default, `http://localhost:3000`) and API documentation at `API_PUBLIC_URL/docs`.
`BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` selects an ordinary initial organization
owner; it does not create a special global-admin role. To integrate
headlessly, set `BOOTSTRAP_DELIVERY_SETUP_API_KEY` and `BOOTSTRAP_TEAM_PROVISIONING_API_KEY` in
`.env`. Bootstrap hashes both keys and never logs them.
See the [Headless organization setup](./apps/docs/content/docs/developers/headless-provisioning.mdx)
for the complete REST sequence, scopes, rotation, and migration from the old
log-generated key. The plain Markdown guide is at
[`apps/docs/integrations/headless-provisioning.md`](./apps/docs/integrations/headless-provisioning.md).

Open the dashboard at `WEB_CLIENT` (by default,
`http://localhost:3000`) and API documentation at `API_PUBLIC_URL/docs`.

For an internet-facing deployment, set `API_PUBLIC_URL`, `WEB_CLIENT`,
`PROTOCOL=https`, and `DOMAIN` to the public values before the first start.
Expand Down
77 changes: 68 additions & 9 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,67 @@
# Postgres connection string
DB_CONNECTION_STRING=postgres://sendlit:sendlit@localhost:5432/sendlit
DB_CONNECTION_STRING=postgres://sendlit:sendlit@localhost:5434/sendlit

# Redis (used by BullMQ for mail sending + sequence delivery)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PORT=6380

PORT=5000
NODE_ENV=development

# Billing deployment mode is explicit. Use oss for local/self-hosted installs.
SENDLIT_DEPLOYMENT_MODE=oss
# Cloud-only catalog/provider settings (required when mode=cloud):
# BILLING_CHECKOUT_PROVIDER=dodo
# BILLING_ENABLED_PROVIDERS=dodo,stripe
# Tests may use BILLING_CHECKOUT_PROVIDER=fake. Production cloud refuses fake.
# BILLING_CATALOG_REVISION=<positive integer>
# BILLING_CURRENCY=<ISO 4217 code>
# BILLING_PRO_MONTH_AMOUNT_MINOR=<positive integer>
# BILLING_PRO_YEAR_AMOUNT_MINOR=<positive integer>
# BILLING_BUSINESS_MONTH_AMOUNT_MINOR=<positive integer>
# BILLING_BUSINESS_YEAR_AMOUNT_MINOR=<positive integer>
# DODO_PAYMENTS_API_KEY=
# DODO_PAYMENTS_WEBHOOK_KEY_CURRENT=
# DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS=
# DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT=
# DODO_PAYMENTS_ENVIRONMENT=test_mode
# Maximum age of the sign-in that may mint a single-use billing action token.
# BILLING_RECENT_AUTH_MAX_AGE_SECONDS=900
# DODO_PRO_MONTH_PRODUCT_ID=
# DODO_PRO_YEAR_PRODUCT_ID=
# DODO_BUSINESS_MONTH_PRODUCT_ID=
# DODO_BUSINESS_YEAR_PRODUCT_ID=
# Billing checkout URLs and webhook payloads are encrypted at rest.
# BILLING_DATA_ENCRYPTION_KEY= # base64 encoding of exactly 32 random bytes
# BILLING_DATA_ENCRYPTION_KEY_VERSION=v1
# BILLING_DATA_ENCRYPTION_KEY_PREVIOUS= # optional during key rotation
# Optional admin paging for billing SLOs (comma-separated). Unset disables email alerts.
# BILLING_ALERT_EMAIL=
# Operator recovery (catalog verify/abandon, webhook retry, overrides,
# reputation release, emergency cancel) is `pnpm --filter @sendlit/api billing`.
# Required in cloud mode. Dedicated HMAC key for one-time trial eligibility.
# Do not reuse BETTER_AUTH_SECRET. Generate with: openssl rand -base64 32
# BILLING_TRIAL_EMAIL_HMAC_KEY=
# BILLING_TRIAL_EMAIL_HMAC_KEY_VERSION=v1
# BILLING_TRIAL_EMAIL_HMAC_KEY_PREVIOUS=
# BILLING_TRIAL_EMAIL_HMAC_KEY_PREVIOUS_VERSION=
# Fair-use controls (defaults match the published policy; override per deploy).
# BILLING_FAIR_USE_MIN_ACCEPTED=500
# BILLING_FAIR_USE_BOUNCE_WARN_BPS=200
# BILLING_FAIR_USE_COMPLAINT_WARN_BPS=5
# BILLING_FAIR_USE_BOUNCE_PAUSE_BPS=500
# BILLING_FAIR_USE_COMPLAINT_PAUSE_BPS=10
# BILLING_FAIR_USE_COMPLAINT_STOP_BPS=30
# BILLING_FAIR_USE_COMPLAINT_STOP_ABSOLUTE=10
# BILLING_FAIR_USE_TRANSACTIONAL_DAILY_LIMIT=100
# BILLING_FAIR_USE_MINIMUM_HOLD_HOURS=72
# BILLING_FAIR_USE_RECOVERY_CLEAN_DAYS=7
# Paid marketing ramp (messages per organization per UTC day).
# BILLING_RAMP_DAYS_0_2_LIMIT=200
# BILLING_RAMP_DAYS_3_6_LIMIT=1000
# BILLING_RAMP_DAYS_7_13_LIMIT=10000
# BILLING_TEST_VOLUME_THRESHOLD=100

# Public URL Better Auth uses for OAuth callbacks, issuers and trusted origins.
API_PUBLIC_URL=http://localhost:5000

Expand Down Expand Up @@ -50,23 +104,28 @@ SUPPRESSION_HASH_KEY=
# ESP in settings.
# EMAIL_HOST is required in production. EMAIL_USER/EMAIL_PASS are optional
# (leave empty for auth-less sinks like Mailpit).
EMAIL_HOST=
EMAIL_PORT=587
# Local docker-compose.local.yml Mailpit defaults. Override for deployed envs.
EMAIL_HOST=127.0.0.1
EMAIL_PORT=1027
EMAIL_USER=
EMAIL_PASS=
EMAIL_FROM=

# Optional: on boot, if set and no account exists for this email yet, creates
# one (with its default team + API key) and logs the key once — a dev/
# self-host convenience, mirroring MediaLit's admin-bootstrap script. See
# src/bootstrap.ts.
SUPER_ADMIN_EMAIL=
# Optional: on boot, ensure this initial organization owner and their default
# organization/team exist. This selects an ordinary owner, not a global-admin
# role. Both configured organization API keys are read from environment
# variables, hashed, and never logged.
BOOTSTRAP_ORGANIZATION_OWNER_EMAIL=
BOOTSTRAP_DELIVERY_SETUP_API_KEY=
BOOTSTRAP_TEAM_PROVISIONING_API_KEY=

# Optional: PostHog error tracking, product analytics and log shipping.
# When POSTHOG_API_KEY is set, all pino logs (src/services/log.ts) at
# POSTHOG_LOG_LEVEL and above are also shipped to PostHog via OTLP, and
# errors/events are captured via src/observability/posthog.ts. When unset,
# telemetry is fully disabled and logs go to stdout only.
# apps/web uses the same POSTHOG_API_KEY / POSTHOG_HOST at request time
# (not NEXT_PUBLIC_*) for identify and session recording.
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Minimum level shipped to PostHog (default info). stdout level is LOG_LEVEL.
Expand Down
1 change: 1 addition & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ COPY --from=deps /app/ ./
RUN pnpm --filter=@sendlit/email-editor build
RUN pnpm --filter=@sendlit/api-contract build
RUN pnpm --filter=@sendlit/email-blocks build
RUN pnpm --filter=@sendlit/api billing:generate
RUN pnpm --filter=@sendlit/api build

FROM base AS runner
Expand Down
Loading
Loading