This file provides guidance to AI Agents when working with code in this repository.
Always use yarn (v1) for all commands. This repository uses yarn workspaces, not npm.
Ghost is a Yarn v1 + Nx monorepo with three workspace groups:
- ghost/core - Main Ghost application (Node.js/Express backend)
- Core server:
ghost/core/core/server/ - Frontend rendering:
ghost/core/core/frontend/
- Core server:
- ghost/admin - Ember.js admin client (legacy, being migrated to React)
- ghost/i18n - Centralized internationalization for all apps
Two categories of apps:
Admin Apps (embedded in Ghost Admin):
admin-x-settings,admin-x-activitypub- Settings and integrationsposts,stats- Post analytics and site-wide analytics- Built with Vite + React +
@tanstack/react-query
Public Apps (served to site visitors):
portal,comments-ui,signup-form,sodo-search,announcement-bar- Built as UMD bundles, loaded via CDN in site themes
Foundation Libraries:
admin-x-framework- Shared API hooks, routing, utilitiesadmin-x-design-system- Legacy design system (being phased out)shade- New design system (shadcn/ui + Radix UI + react-hook-form + zod)
- Playwright-based E2E tests with Docker container isolation
- See
e2e/CLAUDE.mdfor detailed testing guidance
yarn # Install dependencies
yarn setup # First-time setup (installs deps + submodules)
yarn dev # Start development (Docker backend + host frontend dev servers)yarn build # Build all packages (Nx handles dependencies)
yarn build:clean # Clean build artifacts and rebuild# Unit tests (from root)
yarn test:unit # Run all unit tests in all packages
# Ghost core tests (from ghost/core/)
cd ghost/core
yarn test:unit # Unit tests only
yarn test:integration # Integration tests
yarn test:e2e # E2E API tests (not browser)
yarn test:browser # Playwright browser tests for core
yarn test:all # All test types
# E2E browser tests (from root)
yarn test:e2e # Run e2e/ Playwright tests
# Running a single test
cd ghost/core
yarn test:single test/unit/path/to/test.test.jsyarn lint # Lint all packages
cd ghost/core && yarn lint # Lint Ghost core (server, shared, frontend, tests)
cd ghost/admin && yarn lint # Lint Ember adminyarn knex-migrator migrate # Run database migrations
yarn reset:data # Reset database with test data (1000 members, 100 posts) (requires yarn dev running)
yarn reset:data:empty # Reset database with no data (requires yarn dev running)yarn docker:build # Build Docker images
yarn docker:clean # Stop containers, remove volumes and local images
yarn docker:down # Stop containersThe yarn dev command uses a hybrid Docker + host development setup:
What runs in Docker:
- Ghost Core backend (with hot-reload via mounted source)
- MySQL, Redis, Mailpit
- Caddy gateway/reverse proxy
What runs on host:
- Frontend dev servers (Admin, Portal, Comments UI, etc.) in watch mode with HMR
- Foundation libraries (shade, admin-x-framework, etc.)
Setup:
# Start everything (Docker + frontend dev servers)
yarn dev
# With optional services (uses Docker Compose file composition)
yarn dev:analytics # Include Tinybird analytics
yarn dev:storage # Include MinIO S3-compatible object storage
yarn dev:all # Include all optional servicesAccessing Services:
- Ghost:
http://localhost:2368(database:ghost_dev) - Mailpit UI:
http://localhost:8025(email testing) - MySQL:
localhost:3306 - Redis:
localhost:6379 - Tinybird:
http://localhost:7181(when analytics enabled) - MinIO Console:
http://localhost:9001(when storage enabled) - MinIO S3 API:
http://localhost:9000(when storage enabled)
Build Process:
- Admin-x React apps build to
apps/*/distusing Vite ghost/admin/lib/asset-deliverycopies them toghost/core/core/built/admin/assets/*- Ghost admin serves from
/ghost/assets/{app-name}/{app-name}.js
Runtime Loading:
- Ember admin uses
AdminXComponentto dynamically import React apps - React components wrapped in Suspense with error boundaries
- Apps receive config via
additionalProps()method
- Built as UMD bundles to
apps/*/umd/*.min.js - Loaded via
<script>tags in theme templates (injected by{{ghost_head}}) - Configuration passed via data attributes
Centralized Translations:
- Single source:
ghost/i18n/locales/{locale}/{namespace}.json - Namespaces:
ghost,portal,signup-form,comments,search - 60+ supported locales
Critical build order (Nx handles automatically):
shade+admin-x-design-systembuildadmin-x-frameworkbuilds (depends on #1)- Admin apps build (depend on #2)
ghost/adminbuilds (depends on #3, copies via asset-delivery)ghost/coreserves admin build
Follow the project's commit message format:
- 1st line: Max 80 chars, past tense, with emoji if user-facing
- 2nd line: [blank]
- 3rd line:
ref,fixes, orcloseswith issue link - 4th line: Context (why this change, why now)
Emojis for user-facing changes:
- ✨ Feature
- 🎨 Improvement/change
- 🐛 Bug fix
- 🌐 i18n/translation
- 💡 Other user-facing changes
Example:
✨ Added dark mode toggle to admin settings
fixes https://github.com/TryGhost/Ghost/issues/12345
Users requested ability to switch themes for better accessibility
- New features: Build in React (
apps/admin-x-*orapps/posts) - Use:
admin-x-frameworkfor API hooks (useBrowse,useEdit, etc.) - Use:
shadedesign system for new components (not admin-x-design-system) - Translations: Add to
ghost/i18n/locales/en/ghost.json
- Edit:
apps/portal,apps/comments-ui, etc. - Translations: Separate namespaces (
portal.json,comments.json) - Build: UMD bundles for CDN distribution
- Core logic:
ghost/core/core/server/ - Database Schema:
ghost/core/core/server/data/schema/ - API routes:
ghost/core/core/server/api/ - Services:
ghost/core/core/server/services/ - Models:
ghost/core/core/server/models/ - Frontend & theme rendering:
ghost/core/core/frontend/
- New components: Use
shade(shadcn/ui-inspired) - Legacy:
admin-x-design-system(being phased out, avoid for new work)
- Local development:
yarn dev:analytics(starts Tinybird + MySQL) - Config: Add Tinybird config to
ghost/core/config.development.json - Scripts:
ghost/core/core/server/data/tinybird/scripts/ - Datafiles:
ghost/core/core/server/data/tinybird/
yarn fix # Clean cache + node_modules + reinstall
yarn build:clean # Clean build artifacts
yarn nx reset # Reset Nx cache- E2E failures: Check
e2e/CLAUDE.mdfor debugging tips - Docker issues:
yarn docker:clean && yarn docker:build
This fork adds Postmark as a first-class bulk email provider alongside Mailgun. The integration is designed so both providers share the same interface and can be swapped at runtime via a database setting.
| Package | Path | Description |
|---|---|---|
@tryghost/postmark-client |
ghost/postmark-client/ |
Wraps the postmark npm SDK. Mirrors MailgunClient's interface for sending, event fetching, and suppression removal. |
@tryghost/email-analytics-provider-postmark |
ghost/email-analytics-provider-postmark/ |
Implements the EmailAnalyticsProvider interface. Registered alongside the Mailgun provider so analytics are fetched from whichever provider is active. |
Option 1 — Admin UI (stores in DB, recommended):
- Ghost Admin → Settings → Email newsletter → Email provider
- Select Postmark from the dropdown
- Enter the Postmark Server API Token
- Save — stores
bulk_email_provider = 'postmark'andpostmark_api_tokenin thesettingstable
Option 2 — Config file (takes precedence over DB settings):
{
"bulkEmail": {
"postmark": {
"apiToken": "your-postmark-server-api-token",
"streamId": "broadcast"
}
}
}When using a config file, also set bulk_email_provider = 'postmark' via the Admin UI or API, because email-service-wrapper.js routes sends based on that DB setting.
| Key | Group | Default | Notes |
|---|---|---|---|
bulk_email_provider |
email |
'mailgun' |
'mailgun' or 'postmark' |
postmark_api_token |
email |
null |
Postmark Server API Token |
postmark_stream_id |
— | 'broadcast' |
Read from settings but not in default-settings.json; config-file only |
postmark_api_token is in the EDITABLE_SETTINGS allowlist in ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js so it can be written via the Admin API.
email-service-wrapper.js :: getMailClient()
bulk_email_provider === 'postmark' → new PostmarkClient(...)
else → new MailgunClient(...)
↓
BulkEmailProvider (shared interface)
↓
PostmarkClient.send() → postmark SDK → sendEmailBatch()
--- Analytics ---
email-analytics-service-wrapper.js registers BOTH providers:
[EmailAnalyticsProviderMailgun, EmailAnalyticsProviderPostmark]
Each provider's fetchLatest() is called every cycle; PostmarkClient
exits early with a warning if not configured.
--- Suppression removal ---
MailgunEmailSuppressionList is used polymorphically — PostmarkClient
implements removeBounce/removeComplaint/removeUnsubscribe, which call
Postmark's deleteSuppressions() API.
| File | What changed |
|---|---|
ghost/postmark-client/lib/PostmarkClient.js |
Core client: send, fetchEvents, normalizeEvent, removeSuppression |
ghost/email-analytics-provider-postmark/lib/EmailAnalyticsProviderPostmark.js |
Analytics provider wrapper |
ghost/core/core/server/services/email-service/email-service-wrapper.js |
getMailClient() branches on bulk_email_provider |
ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js |
Registers both analytics providers |
ghost/core/core/server/services/email-service/BulkEmailProvider.js |
JSDoc + error-handling branch for Postmark (TODO: errors currently dropped) |
ghost/core/core/server/services/public-config/config.js |
Exposes postmarkIsConfigured to frontend |
ghost/core/core/server/data/schema/default-settings/default-settings.json |
postmark_api_token default setting |
apps/admin-x-settings/src/components/settings/email/BulkEmail.tsx |
Unified provider-selector UI (Mailgun + Postmark) |
ghost/admin/app/services/settings.js |
bulkEmailIsConfigured includes Postmark token check |
BulkEmailProvider.jshas an explicit@TODO: handle postmark errors— Postmark errors are currently silently dropped.postmark_stream_idcannot be set via the Admin UI; config-file only.ghost/core/test/unit/server/services/email-service/bulk-email-provider.test.jshas unresolved git merge conflict markers and will not run.ghost/postmark-client/test/postmark-client.test.jsandghost/email-analytics-provider-postmark/test/provider-postmark.test.jsare empty stubs.EmailAnalyticsProviderPostmarkalways fetches only the last 5 minutes of open events regardless of thebegin/endoptions passed to it.
cd ghost/postmark-client && yarn test
cd ghost/email-analytics-provider-postmark && yarn test