Last updated: 2026-04-05.
- Node.js 20+
- PostgreSQL 14+ (local install or Docker)
- Railway CLI — for production deploys:
npm install -g @railway/cli
npm installOption A: Docker (recommended)
docker-compose up -d
# Postgres will be available at postgresql://pauseai:pauseai@localhost:5432/pauseaiOption B: Local Postgres
createdb pauseai
# Then set DATABASE_URL=postgresql://localhost:5432/pauseai in your .envcp .env.example .envEdit .env:
| Variable | Description | How to get it |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | See above |
NEXTAUTH_SECRET |
Random string for JWT signing | Any random string in dev |
NEXTAUTH_URL |
Base URL of the app | http://localhost:3000 |
AUTH_GOOGLE_ID |
Google OAuth client ID | Google Cloud Console |
AUTH_GOOGLE_SECRET |
Google OAuth client secret | Same as above |
MAILERSEND_API_KEY |
Mailersend API key | Mailersend dashboard |
MAILERSEND_FROM_EMAIL |
Default sender email | A verified Mailersend sender |
ADMIN_EMAILS |
Comma-separated emails auto-promoted to admin | e.g. you@example.com |
UNSUBSCRIBE_SECRET |
HMAC secret for unsubscribe tokens | openssl rand -hex 32 |
NEXT_PUBLIC_APP_URL |
Public URL for unsubscribe links | http://localhost:3000 |
EMAIL_ENCRYPTION_KEY |
AES-256 key for encrypting OAuth tokens and connection credentials | openssl rand -hex 32 |
MAILERSEND_WEBHOOK_SIGNING_SECRET |
HMAC secret for verifying Mailersend webhook signatures | From Mailersend dashboard (optional in dev) |
TALLY_WEBHOOK_SIGNING_SECRET |
HMAC secret for verifying Tally webhook signatures | From Tally form settings (optional in dev) |
EMAIL_MODE |
sandbox (default if unset) — captures all outbound email in the database instead of sending via Mailersend. Set to live only when you want to send real emails. Leave unset or set to sandbox for all development and testing. |
No (defaults to sandbox) |
DEV_BYPASS_AUTH |
Set to true to skip Google login in dev |
Dev only |
Two auth modes for development:
-
DEV_BYPASS_AUTH=true— skips login entirely, auto-logged in as "Dev User" with admin access. Simplest option for single-user dev. Only activates whenNODE_ENV=development. -
Dev login (recommended for workspace testing) — set
DEV_BYPASS_AUTH=false(or unset) and go to/login. You'll see a dev login form with preset users:- Admin (admin@pauseai.info) — global admin, full access to all workspaces
- Member (member@pauseai.info) — global member, member of Global workspace
- Viewer (viewer@pauseai.info) — global viewer, read-only access
- France Chapter Admin (france@pauseai.info) — global member, admin of France workspace only
You can also enter a custom email/name/role and select which workspace to add the user to. This creates real user records and workspace memberships in the database, allowing you to test the full multi-tenancy flow by switching between users.
npx drizzle-kit pushThis introspects the Drizzle schema files in src/db/schema/ and creates/updates all tables in your local database. No SQL files needed.
npm run db:seedIn two terminals:
# Terminal 1: Web server
npm run dev
# Terminal 2: Background worker (needed for campaign sending, scripts, etc.)
npm run workerOpen http://localhost:3000. With DEV_BYPASS_AUTH=true you'll land directly on the dashboard. With it unset, you'll see the dev login page where you can choose a user to test with.
By default, no real emails are sent. The app starts in sandbox mode (EMAIL_MODE=sandbox or unset), which intercepts all outbound email — campaigns, previews, script-triggered sends, invitations, ticket notifications — and writes them to the sandbox_emails database table instead of calling the Mailersend API. Zero HTTP requests are made to Mailersend when in sandbox mode.
This means you can safely:
- Send campaigns to thousands of contacts during testing
- Run end-to-end tests (UI tests, API tests, AI-driven tests) that exercise the full email pipeline
- Inspect exactly what the system would send: rendered HTML, headers, recipient, merge variables
- Simulate delivery events (delivered, opened, clicked, bounced, unsubscribed) to test the full email lifecycle
- Leave
EMAIL_MODEunset (or set it tosandbox) — this is the default - You'll see an amber banner at the top of the dashboard: "SANDBOX MODE — No emails are being sent"
- A Sandbox item appears in the sidebar (admin-only, flask icon) linking to
/dashboard/sandbox - The sandbox viewer shows all captured emails with filters, detail view, and event simulation buttons
The sandbox is fully drivable via API (all endpoints require admin role):
| Endpoint | Method | Description |
|---|---|---|
/api/sandbox/status |
GET | Check current mode (sandbox or live) |
/api/sandbox/emails |
GET | List captured emails (filters: campaignId, to, workspaceId, status, since) |
/api/sandbox/emails/:id |
GET | Get full email detail (rendered HTML, headers, etc.) |
/api/sandbox/emails/:id/simulate |
POST | Simulate an event on one email (delivered, opened, clicked, bounced, unsubscribed) |
/api/sandbox/emails/simulate-bulk |
POST | Simulate an event on all matching emails |
/api/sandbox/emails |
DELETE | Clear sandbox data (optionally scoped to a campaign) |
Event simulation calls the same internal logic as the Mailersend webhook handler — it updates the emails table, recalculates campaign stats, and processes unsubscribes. This tests the real code path, not a fake one.
To actually send emails (e.g., for production or manual testing against real Mailersend):
EMAIL_MODE=liveSet this in your .env file and restart the dev server and worker. For production, EMAIL_MODE=live is required — see deployment.md.
- Frontend tests: The sandbox banner confirms you're in the right mode. Use the sandbox viewer to inspect sent emails.
- API tests: Use
DELETE /api/sandbox/emailsto clear state, then run your test flow, thenGET /api/sandbox/emailsto verify what was sent. - End-to-end tests: The sandbox API enables fully automated test flows — create contacts, send campaigns, simulate delivery events, verify stats — all without sending real email.
- Edit the relevant file in
src/db/schema/ - Run
npx drizzle-kit pushto apply changes to your local DB - When ready to deploy to production,
drizzle-kit pushalso runs as part of the web service start command
The project uses push-based migrations (not drizzle-kit migrate with migration files). This keeps things simple for a small team. If you need a history of migrations, switch to drizzle-kit generate + drizzle-kit migrate.
- Create
src/app/api/your-thing/route.ts - Use
checkAuth()fromsrc/lib/api-auth.tsfor authentication - Use
requireAdmin()if the endpoint is admin-only - Add business logic to a corresponding
src/lib/your-thing.tsfile (keep routes thin)
// Example: src/app/api/things/route.ts
import { NextRequest, NextResponse } from "next/server";
import { checkAuth, requireAdmin } from "@/lib/api-auth";
import { listThings } from "@/lib/things";
export async function GET(request: NextRequest) {
const auth = await checkAuth(request);
if (!auth.authenticated) return auth.error!;
const things = await listThings();
return NextResponse.json(things);
}- Create
src/worker/tasks/my-task.ts:
import type { Task } from "graphile-worker";
export const myTask: Task = async (payload, helpers) => {
// payload is the job's JSON data
helpers.logger.info("Doing the thing...");
// throw to trigger graphile-worker's retry logic
};- Register it in
src/worker/index.ts:
import { myTask } from "./tasks/my-task";
const taskList = {
// ...existing tasks...
my_task: myTask,
};- To enqueue from the web, use
src/lib/worker-client.ts:
import { addJob } from "@/lib/worker-client";
await addJob("my_task", { someData: 123 });- To add a cron trigger, add to
parseCronItemsinsrc/worker/index.ts:
{ task: "my_task", match: "0 8 * * *", identifier: "daily_my_task" }- Create
src/lib/connectors/my-source.tsimplementing theConnectorinterface:
import { Connector, ExternalField, ExternalResource, FetchResult } from "./types";
import { z } from "zod";
const CredentialsSchema = z.object({ apiKey: z.string() });
export const mySourceConnector: Connector = {
type: "my_source",
label: "My Source",
credentialsSchema: CredentialsSchema,
async testConnection(credentials) { /* validate creds, return success message */ },
async listResources(credentials) { /* return available tables/databases */ },
async getSchema(credentials, resource) { /* return field definitions */ },
async fetchRecords(credentials, resource, cursor?) { /* return records + optional next cursor */ },
};- Register it in
src/lib/connectors/index.ts:
import { mySourceConnector } from "./my-source";
// Add to the connectors map- Add the type to
CONNECTOR_TYPESinsrc/lib/connectors/types.ts:
{ type: "my_source", label: "My Source", available: true },- Add credential fields to the connection creation UI in
src/components/connections-manager.tsx(the credential form is connector-specific).
npm test # run all tests once
npm run test:watch # watch modeTests live in src/lib/__tests__/. The test suite uses Vitest. Currently, unit tests cover the script engine and segment query builder. API integration tests are a known gap.
HTTP requests → Next.js (web process)
↓
PostgreSQL DB ← graphile-worker (worker process)
The web process handles API routes and the dashboard UI. Heavy work (sending campaigns, running scripts) is enqueued as jobs into the Postgres-backed graphile-worker queue. The worker picks these up asynchronously.
See architecture.md for the full design.
Flexible fields: Contacts store all custom data in a customFields JSONB column. Field definitions are rows in the field_definitions table, not code. Admins can add/remove fields without schema changes.
Segment filters: Segments are stored as JSON (a SegmentFilter type). The buildSegmentWhere() function in src/lib/segments.ts translates these into Drizzle where() clauses.
Script engine: User-defined JavaScript runs in a Node.js vm sandbox with a ctx SDK injected (contacts, tags, email operations). 30s timeout, max 1000 contacts per run. See src/lib/script-engine.ts.
Auth: Two auth paths — browser sessions (Google OAuth → JWT via Auth.js) and API keys (Bearer token with pai_ prefix, hashed in DB). Checked by checkAuth() in src/lib/api-auth.ts.
npm run db:studio # opens Drizzle Studio at https://local.drizzle.studioAll table definitions are in src/db/schema/:
| File | Tables |
|---|---|
contacts.ts |
contacts, contact_tags |
fields.ts |
field_definitions |
interactions.ts |
interactions |
tags.ts |
tags |
segments.ts |
segments |
campaigns.ts |
campaigns |
emails.ts |
emails |
scripts.ts |
scripts, script_runs |
automations.ts |
automation_rules |
communication-categories.ts |
communication_categories |
app-settings.ts |
app_settings (key-value store) |
connections.ts |
connections, sync_configurations, sync_runs |
email-connections.ts |
email_connections, email_contact_settings |
sandbox-emails.ts |
sandbox_emails (captured emails in sandbox mode) |
users.ts |
user, account, session, verificationToken (NextAuth tables) |
index.ts |
Re-exports all schemas |
-- Check contact count
SELECT COUNT(*) FROM contacts;
-- See all users
SELECT id, email, name, is_admin FROM "user";
-- See recent campaigns
SELECT id, name, status, sent_count, scheduled_at, sent_at
FROM campaigns ORDER BY created_at DESC LIMIT 10;
-- See recent script runs
SELECT s.name, r.status, r.started_at, r.contacts_affected
FROM script_runs r
JOIN scripts s ON s.id = r.script_id
ORDER BY r.started_at DESC LIMIT 20;
-- Check graphile-worker job queue
SELECT id, task_identifier, payload, attempts, run_at
FROM graphile_worker.jobs ORDER BY created_at DESC LIMIT 20;railway service web && railway logs -n 50 # web logs
railway service worker && railway logs -n 50 # worker logsrailway service web && railway variables # view web env vars
railway variables set KEY=value # set a variable (triggers redeploy)Connect via DATABASE_URL from the Railway Postgres service. You can get it with:
railway service Postgres-JwGd && railway variablesThen use psql, Drizzle Studio, or TablePlus to connect.
drizzle-kit push fails with "relation does not exist"
Your schema references a table that doesn't exist yet. Run npx drizzle-kit push again — it resolves dependencies automatically.
Worker crashes on startup
Check DATABASE_URL is set. The worker requires it and will throw immediately if missing.
Gmail integration setup
The personal email integration requires a Google Cloud project with the Gmail API enabled. We use the pauseai-everything GCP project for this. The OAuth consent screen is set to External (so users with any email domain can connect) and the Gmail API is enabled.
The same OAuth client (AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET) is used for both login and Gmail. The following authorized redirect URIs must be configured in Google Cloud Console:
http://localhost:3000/api/auth/callback/google(login — dev)http://localhost:3000/api/auth/gmail/callback(Gmail — dev)https://web-production-4523c.up.railway.app/api/auth/callback/google(login — prod)https://web-production-4523c.up.railway.app/api/auth/gmail/callback(Gmail — prod)
And these authorized JavaScript origins:
http://localhost:3000https://web-production-4523c.up.railway.app
Set EMAIL_ENCRYPTION_KEY to a random 32-byte hex string (openssl rand -hex 32). This must be the same value in both the web and worker services.
Google OAuth "redirect_uri_mismatch"
The callback URL http://localhost:3000/api/auth/callback/google must be in your Google Cloud Console's authorized redirect URIs.
Campaign stuck in "sending" status
The worker wasn't running when the campaign was sent, or it failed mid-send. Reset the campaign status in the DB (UPDATE campaigns SET status = 'draft' WHERE id = '...'), then retry.
error: column X of relation Y does not exist
The schema was changed but drizzle-kit push hasn't been run yet. Run it, or in production, redeploy the web service (which runs drizzle-kit push on startup).