From 68a12916c5cf4ba5ecbb783160d307f5891830f3 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Thu, 8 Oct 2026 15:36:01 -0400 Subject: [PATCH] Added an optional Mailpit for local sites closes https://linear.app/ghost/issue/PLA-502/optional-mailpit-for-local-sites-install-local-with-mailpit A local site has no mail configured, so Ghost cannot deliver staff invites, password resets or member sign-in links, and sign-in links are what theme development needs most. `install --local --with mailpit` now runs Mailpit beside Ghost and points Ghost's SMTP settings at it, so that mail lands in a web inbox on the loopback interface. It is opt-in and local only. In production it would quietly swallow the site's real mail, so `--domain` with it is a usage error and validation rejects the profile beside `production`. Ghost's settings are written into ghost.env rather than set by compose.yml, because container-owned keys would override an operator's or an import's real SMTP; the lines are the operator's afterwards. Each site gets its own Mailpit, with its inbox port chosen the way Ghost's is and its messages kept in data/mailpit. An import may take it too, and only it: a local copy of a real site then sends nothing to real members. The source's mail transport is dropped and named, and its mail__from kept. Newsletters go through Mailgun's API, not SMTP, so Mailpit never sees them; the docs say so. - compose.yml: the mailpit service, pinned by digest, under its own profile, with a readiness health check and a unique network alias. - manager/src/site.ts, config.ts, undo.ts: the profile, the local-only rule, and clean-up of a failed install's Mailpit. - manager/src/commands/install.ts: --with mailpit, its port, ghost.env settings, data directory and summary line. labs__publicAPI is now set for ActivityPub only, not for any optional service. - manager/src/import.ts, import/config.ts: --with mailpit on import, replacing the source's mail transport. - manager/src/verify.ts, commands/site.ts: check and install verify Mailpit's health and its SMTP greeting from the ghost container; info prints the inbox URL. - tests/e2e/install.sh, import.sh: a password reset from Ghost arrives in Mailpit, for a new site and an imported one; the inbox survives a restart. - docs/install.md, configuration.md, bundle-v1.md, .env.example, AGENTS.md: documented. --- .env.example | 15 +++- AGENTS.md | 4 +- compose.yml | 43 +++++++++- docs/bundle-v1.md | 5 ++ docs/configuration.md | 16 +++- docs/install.md | 34 ++++++-- manager/src/commands/install.ts | 139 +++++++++++++++++++++++--------- manager/src/commands/site.ts | 10 ++- manager/src/config.ts | 12 +++ manager/src/import.ts | 29 +++++-- manager/src/import/config.ts | 20 +++++ manager/src/site.ts | 11 ++- manager/src/undo.ts | 2 +- manager/src/verify.ts | 56 ++++++++++++- manager/test/config.test.ts | 9 +++ manager/test/import.test.ts | 34 +++++++- manager/test/install.test.ts | 65 +++++++++++++++ manager/test/meta.test.ts | 11 +++ manager/test/verify.test.ts | 36 ++++++++- tests/e2e/import.sh | 29 ++++++- tests/e2e/install.sh | 39 +++++++++ 21 files changed, 549 insertions(+), 70 deletions(-) diff --git a/.env.example b/.env.example index 0df251d1..b6f95d30 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,8 @@ # --- Site mode ------------------------------------------------------------- # Exactly one site mode must be selected: `local` or `production`. # Optional per-site profiles are added to the same list: `analytics`, -# `activitypub`. Profiles are additive; adding an optional profile never -# changes the site mode. +# `activitypub`, and for a local site `mailpit`. Profiles are additive; adding +# an optional profile never changes the site mode. COMPOSE_PROFILES="production" SITE_MODE="production" @@ -92,6 +92,7 @@ DATABASE_EXTRA_DATABASES="activitypub" # --- Data locations ------------------------------------------------------- UPLOAD_LOCATION="./data/ghost" MYSQL_DATA_LOCATION="./data/mysql" +# MAILPIT_DATA_LOCATION="./data/mailpit" # --- Container logs ------------------------------------------------------- # Container logs are capped so a long-running site cannot fill the disk. @@ -109,6 +110,16 @@ LOG_MAX_FILE="3" # # ACTIVITYPUB_DATABASE_NAME="activitypub" +# --- Mailpit (optional, local sites only) ------------------------------------- +# Add `mailpit` to COMPOSE_PROFILES of a local site to catch the mail Ghost +# sends in a web inbox, published on the loopback interface only. Ghost sends +# to it when ghost.env's mail__* settings name it; `install --with mailpit` +# writes them. Never in production: it would catch the site's real mail. +# +# Resource cost: one long-running Go service (~20-30 MB RSS). +# +# MAILPIT_PORT="8025" + # --- Analytics (optional, per-site) --------------------------------------- # Add `analytics` to COMPOSE_PROFILES. Tinybird credentials, workspace # selection and schema deployment belong to this site; see TINYBIRD.md. diff --git a/AGENTS.md b/AGENTS.md index 020eb966..08ef6cfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,9 @@ delivers it. `docs/install.md` describes what exists. (`src/verify.ts`): `127.0.0.1` in the manager is the manager, and it cannot reach the host's ports. -- `compose.yml` — Ghost, MySQL, Caddy, optional analytics and ActivityPub. +- `compose.yml` — Ghost, MySQL, Caddy, optional analytics and ActivityPub, + and for local sites Mailpit (`--with mailpit`; validation keeps it out of + production). Site mode is `local` or `production`, selected in `COMPOSE_PROFILES`; optional profiles are additive. Long-running services take `RESTART_POLICY`; one-shot jobs keep `restart: "no"`. diff --git a/compose.yml b/compose.yml index b52be5e2..a2943b50 100644 --- a/compose.yml +++ b/compose.yml @@ -13,7 +13,8 @@ # Site modes are selected through COMPOSE_PROFILES and are additive: # local ghost + db, Ghost published on 127.0.0.1:${GHOST_PORT} # production ghost + db + caddy (HTTPS ingress) -# Optional, per-site profiles: analytics, activitypub. +# Optional, per-site profiles: analytics, activitypub, and for local sites +# mailpit. # `supervisor` is reserved for the upgrade supervisor and defines no service yet. x-logging: &default-logging @@ -94,6 +95,9 @@ services: activitypub: condition: service_started required: false + mailpit: + condition: service_healthy + required: false networks: ghost_network: aliases: @@ -219,6 +223,43 @@ services: aliases: - activitypub-${COMPOSE_PROJECT_NAME:-ghost} + # Local sites only: catches the mail Ghost sends (staff invites, password + # resets, member sign-in links) and shows it in a web inbox. Newsletters go + # through Mailgun's API, not SMTP, so they never arrive here. + mailpit: + image: axllent/mailpit:v1.31.4@sha256:b68349e3a014b90c5610bfb26b2ae36f3892d7b8cf25ee140c6c71c98d2fcf48 + restart: ${RESTART_POLICY:-unless-stopped} + profiles: [mailpit] + labels: + <<: *site-labels + org.ghost.docker.role: mailpit + org.ghost.docker.lifecycle: long-running + logging: *default-logging + # The inbox, on the loopback interface only, like Ghost. SMTP stays on the + # site's network. + ports: + - "127.0.0.1:${MAILPIT_PORT:-8025}:8025" + expose: + - "1025" + environment: + MP_DATABASE: /data/mailpit.db + # Ghost may be configured with SMTP credentials; any are accepted. + MP_SMTP_AUTH_ACCEPT_ANY: "true" + MP_SMTP_AUTH_ALLOW_INSECURE: "true" + volumes: + - ${MAILPIT_DATA_LOCATION:-./data/mailpit}:/data + healthcheck: + test: [CMD, /mailpit, readyz] + interval: 30s + timeout: 10s + start_period: 30s + start_interval: 1s + retries: 5 + networks: + ghost_network: + aliases: + - mailpit-${COMPOSE_PROJECT_NAME:-ghost} + # Supporting one-shot jobs. # # These are not long-running services: they keep `restart: "no"` so a completed diff --git a/docs/bundle-v1.md b/docs/bundle-v1.md index 9f307cc0..1fcd5f42 100644 --- a/docs/bundle-v1.md +++ b/docs/bundle-v1.md @@ -164,6 +164,11 @@ overridden: - `process`, `logging__transports`, `logging__path` - upgrade-adapter controls +With `install --import --with mailpit`, the importer also drops the source's +mail transport (`mail__transport`, `mail__options__*`) and writes the site's +Mailpit in its place, so a local copy never sends real mail. `mail__from` is +kept. + Public and admin URLs are mapped deliberately into `.env` (`URL`, `ADMIN_URL`; the domains are their hosts), preserving supported path and port semantics or rejecting an unsupported URL with a clear message. Operator overrides of diff --git a/docs/configuration.md b/docs/configuration.md index d5de04b7..2c07b7e2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,6 +126,12 @@ Adding one never changes the site mode: | --- | --- | --- | | `analytics` | `traffic-analytics` plus the Tinybird one-shot jobs | one long-running Node service (~100-200 MB RSS) and a Tinybird workspace | | `activitypub` | `activitypub`, `activitypub-migrate` | one long-running Node service (~150-250 MB RSS), one migration job per start, one extra MySQL database | +| `mailpit` | `mailpit`, its inbox on `127.0.0.1:${MAILPIT_PORT}` | one long-running Go service (~20-30 MB RSS); its messages in `${MAILPIT_DATA_LOCATION}` | + +`mailpit` is for local sites only: validation rejects it beside `production`, +where it would catch the site's real mail. Selecting the profile only runs +Mailpit; Ghost sends to it because `ghost.env` says so (`install --with +mailpit` writes those settings; see [install.md](install.md#optional-services)). `supervisor` is reserved for the upgrade supervisor and currently defines no service. Any other profile name is rejected by validation. @@ -149,16 +155,17 @@ updates may touch `compose.yml`. ## Lifecycle Long-running services (`ghost`, `db`, `caddy`, `traffic-analytics`, -`activitypub`) use `restart: ${RESTART_POLICY:-unless-stopped}`. +`activitypub`, `mailpit`) use `restart: ${RESTART_POLICY:-unless-stopped}`. One-shot jobs (`activitypub-migrate`, `tinybird-login`, `tinybird-sync`, `tinybird-deploy`) keep `restart: "no"`. A completed or failed job stays stopped; the restart policy is not migration orchestration and is not readiness. -`ghost` and `db` have real health checks: Ghost's probe requires the Admin API -to answer, and MySQL's probe requires a real client connection to the -application database. A running container or a redirect is not readiness. +`ghost`, `db` and `mailpit` have real health checks: Ghost's probe requires +the Admin API to answer, MySQL's probe requires a real client connection to +the application database, and Mailpit's asks its own readiness endpoint. A +running container or a redirect is not readiness. Container logs are capped (`LOG_MAX_SIZE`, `LOG_MAX_FILE`) so a long-running site cannot fill the disk. @@ -171,6 +178,7 @@ Each service has a unique alias suffixed with the project name: - `db-${COMPOSE_PROJECT_NAME}:3306` - `traffic-analytics-${COMPOSE_PROJECT_NAME}:3000` - `activitypub-${COMPOSE_PROJECT_NAME}:8080` +- `mailpit-${COMPOSE_PROJECT_NAME}:1025` (SMTP) Generated proxy routes and helper clients use these, never the bare service name. `COMPOSE_PROJECT_NAME` is the site's stable identity and is kept diff --git a/docs/install.md b/docs/install.md index b403d1e6..5200a292 100644 --- a/docs/install.md +++ b/docs/install.md @@ -30,9 +30,9 @@ directory, and day-to-day operation is plain `docker compose`. ```text ghost-docker install [--local | --domain example.com [--admin-domain admin.example.com] [--email ops@example.com]] - [--port 2368] [--version 6.3.1] [--with activitypub] + [--port 2368] [--version 6.3.1] [--with activitypub,mailpit] [--no-prompt] [--no-start] -ghost-docker install --import BUNDLE [--port 2368] [--no-prompt] [--no-start] +ghost-docker install --import BUNDLE [--port 2368] [--with mailpit] [--no-prompt] [--no-start] ``` | Option | Meaning | @@ -43,7 +43,7 @@ ghost-docker install --import BUNDLE [--port 2368] [--no-prompt] [--no-start] | `--email EMAIL` | The ACME account email. Production only; see below. | | `--port PORT` | The loopback port Ghost is published on, in both modes. Omitted: the first at or above 2368 that is free (see [Ports](#ports-and-your-existing-proxy)). | | `--version VERSION` | A Ghost version (`6.3.1`, which means `6.3.1-next-alpine`) or a full image tag (`6-alpine`). Resolved to an exact digest. | -| `--with LIST` | `activitypub`. `analytics` is added after installation; see below. | +| `--with LIST` | `activitypub`, and `mailpit` for a local site. `analytics` is added after installation; see below. | | `--no-prompt` | Never ask: every input must then be an option. | | `--no-start` | Write the configuration and routes; create no containers. | | `--import BUNDLE` | Import a local Ghost-CLI site from the bundle `ghost migrate-export` made; see [Importing a Ghost-CLI site](#importing-a-ghost-cli-site). | @@ -75,6 +75,21 @@ job and its database; it needs nothing else. It also sets `labs__publicAPI` in `ghost.env`, which ActivityPub and analytics both need. +`--with mailpit`, for a local site only, runs [Mailpit](https://mailpit.axllent.org) +beside Ghost and sends Ghost's mail to it: staff invites, password resets and +member sign-in links land in a web inbox at `http://127.0.0.1:MAILPIT_PORT` +instead of going nowhere. `install` writes the SMTP settings into `ghost.env` +(`mail__transport`, `mail__options__host` set to the site's +`mailpit-${COMPOSE_PROJECT_NAME}` alias, `mail__options__port`, +`mail__options__secure`); they are yours from then on, like any other line +there. The inbox is published on the loopback interface only, on the first port +at or above 8025 that is free, chosen the way Ghost's is, and kept in +`data/mailpit` across restarts. Newsletters are sent through Mailgun's API, +not SMTP, so Mailpit never sees them. A production site cannot select it: +`--domain` with `--with mailpit` is a usage error, and `config validate` +rejects `mailpit` beside `production` in `COMPOSE_PROFILES`, because it would +catch the site's real mail. + Analytics is set up after installation: its Tinybird login is interactive, in a browser, and Ghost waits for the Tinybird jobs to finish, so a site cannot start with it before that login. Add it to an installed site: set the tokens with @@ -89,7 +104,8 @@ with it before that login. Add it to an installed site: set the tokens with 1. **Refuses what it should not touch**, before anything else: a directory that already holds a site (`.env` or `.ghost-docker.json`), stack files the - payload would write over, and non-empty `data/ghost` or `data/mysql`. + payload would write over, and non-empty `data/ghost`, `data/mysql` or + `data/mailpit`. 2. **Preflight**: everything `./ghost-docker doctor` checks (Docker and Compose versions, platform and architecture, the site directory, file ownership, and that a sibling container sees the directory at the same @@ -205,7 +221,12 @@ Bundles are accepted as a directory, a `.tgz`, a plain `.tar`, or a `.zip` needed beyond the launcher's own requirements. Mail settings travel with the configuration. A local site set up to send -through a real mail service will send through it from Docker too. +through a real mail service will send through it from Docker too, unless +`--with mailpit` is given: then the source's `mail__transport` and +`mail__options__*` are left out (and named, as above), Mailpit's are written +instead, and the imported copy's mail, to real members included, goes to the +inbox. `mail__from` is kept. `--with mailpit` is the only optional service an +import takes; enable the others afterwards. Refused, each with a message that says so: @@ -314,6 +335,7 @@ its own containers, and each answer is reported for what it is: | ghost | Its health check, which `up --wait` already required: the Admin API answers inside the container. | `ok` or `ERROR` | | caddy | From the ghost container, over the site's network, Caddy is asked for `http://` and the host of `URL` (and of `ADMIN_URL`), and must redirect to HTTPS, which it does only for a name it serves. | `ok` or `ERROR` | | https | Whether Caddy holds a certificate for the domain. **serving** names the issuer. **pending** means there is none yet: Caddy obtains one once the domain's DNS reaches this host, `./ghost-docker check` reports the change, and `docker compose logs caddy` shows each attempt and why it failed. | `ok` or `note` | +| mailpit | With `--with mailpit`: its health check, and from the ghost container, over the site's network, its SMTP server greets on `mailpit-${COMPOSE_PROJECT_NAME}:1025`, where Ghost sends mail. | `ok` or `ERROR` | | published ports | The ports Docker says it published. A container cannot reach the host's own loopback interface, so they are not checked from there: opening the URL is that check. | `note` | A production site installed before its DNS points at the host therefore passes @@ -325,7 +347,7 @@ offered; for a private name, put `tls internal` in a `caddy/custom/` file. ```bash ./ghost-docker check # diagnose this site: host, configuration, services, database, ingress -./ghost-docker info # the recorded installation metadata +./ghost-docker info # the recorded installation metadata, and where Mailpit's inbox is ./ghost-docker list # every ghost-docker container on this host, stopped ones included ./ghost-docker config get|set|validate ``` diff --git a/manager/src/commands/install.ts b/manager/src/commands/install.ts index 0f13627d..c92ffd16 100644 --- a/manager/src/commands/install.ts +++ b/manager/src/commands/install.ts @@ -52,6 +52,7 @@ import { DATA_DIRS, ENV_FILE, GHOST_ENV_FILE, + MAILPIT_DATA_DIR, META_FILE, readSettings, siteFacts, @@ -64,6 +65,8 @@ import { collect } from './doctor.ts'; /** Where a local site's port search starts, and how far it goes. */ export const DEFAULT_PORT = 2368; +/** Where the search for Mailpit's inbox port starts. */ +export const DEFAULT_MAILPIT_PORT = 8025; const PORT_SEARCH = 200; const PRODUCTION_PORTS = { http: 80, https: 443 } as const; /** Compose waits this long for health checks; pulls and builds come before it. */ @@ -98,7 +101,10 @@ const options = { type: 'string', brief: 'A Ghost version (6.3.1) or image tag (6-alpine). Resolved to an exact digest.', }, - with: { type: 'string', brief: 'Optional per-site services: activitypub.' }, + with: { + type: 'string', + brief: 'Optional per-site services: activitypub, and mailpit for a local site.', + }, import: { type: 'string', brief: 'Import a local Ghost-CLI site from the bundle `ghost migrate-export` made: a directory, .tgz, .tar or .zip.', @@ -178,8 +184,14 @@ async function plan(flags: Flags, prompt: Prompter | null): Promise { } } + const services = optionalServices(flags.with, mode); + return { mode, domain, adminDomain: adminDomain.toLowerCase(), email, services }; +} + +/** The services --with names, for a site of this mode. */ +export function optionalServices(list: string | undefined, mode: SiteMode): string[] { const services: string[] = []; - for (const service of (flags.with ?? '').split(',').map((item) => item.trim())) { + for (const service of (list ?? '').split(',').map((item) => item.trim())) { if (service === '') { continue; } @@ -196,14 +208,19 @@ async function plan(flags: Flags, prompt: Prompter | null): Promise { ' Install without it, then follow TINYBIRD.md.', ); } - if (service !== 'activitypub') { - throw new UsageError(`unknown optional service: ${service} (activitypub)`); + if (service !== 'activitypub' && service !== 'mailpit') { + throw new UsageError(`unknown optional service: ${service} (activitypub, mailpit)`); + } + if (service === 'mailpit' && mode === 'production') { + throw new UsageError( + "--with mailpit is for local sites only: it would catch a production site's real mail", + ); } if (!services.includes(service)) { services.push(service); } } - return { mode, domain, adminDomain: adminDomain.toLowerCase(), email, services }; + return services; } // --- Identity ----------------------------------------------------------------- @@ -231,15 +248,16 @@ export function projectName(mode: SiteMode, domain: string, dir: string): string /** 192 bits, hex: nothing in it a dotenv file, a shell or MySQL treats specially. */ export const secret = (): string => randomBytes(24).toString('hex'); -/** The first port at or above the default that is not taken. */ -export function choosePort(taken: ReadonlySet): number { - for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SEARCH; port += 1) { +/** The first port at or above `start` (Ghost's default) that is not taken. */ +export function choosePort(taken: ReadonlySet, start = DEFAULT_PORT): number { + for (let port = start; port < start + PORT_SEARCH; port += 1) { if (!taken.has(port)) { return port; } } throw new CliError( - `every port from ${DEFAULT_PORT} to ${DEFAULT_PORT + PORT_SEARCH - 1} is taken; choose one with --port`, + `every port from ${start} to ${start + PORT_SEARCH - 1} is taken` + + (start === DEFAULT_PORT ? '; choose one with --port' : ''), ); } @@ -277,6 +295,8 @@ interface Site extends Target { readonly flags: Flags; readonly intent: Plan; readonly port: number; + /** Where Mailpit's inbox is published, with --with mailpit. */ + readonly mailpitPort: number | null; readonly ghost: ResolvedGhost; /** The manager image the launcher is pinned to; none in a checkout. */ readonly pin: string | null; @@ -351,7 +371,7 @@ function refuseOccupied(io: Io, context: Context, dir: string): Target { ); } } - for (const data of DATA_DIRS) { + for (const data of [...DATA_DIRS, MAILPIT_DATA_DIR]) { const path = join(dir, data); if (existsSync(path) && readdirSync(path).length > 0) { throw new CliError( @@ -364,8 +384,14 @@ function refuseOccupied(io: Io, context: Context, dir: string): Target { // --- Before anything is written ----------------------------------------------- -/** An import is of a local site, with no optional services (refuseImportOptions). */ -const IMPORTED: Plan = { mode: 'local', domain: '', adminDomain: '', email: '', services: [] }; +/** An import is of a local site, with no optional services but Mailpit (refuseImportOptions). */ +const imported = (flags: Flags): Plan => ({ + mode: 'local', + domain: '', + adminDomain: '', + email: '', + services: optionalServices(flags.with, 'local'), +}); /** Decides everything about the site, changing nothing. */ async function prepare( @@ -376,9 +402,14 @@ async function prepare( ): Promise { const { io, context, dir, clone } = target; // Asked only now, so nobody answers questions to be told the directory is taken. - const intent = importing === null ? await plan(flags, io.prompt) : IMPORTED; + const intent = importing === null ? await plan(flags, io.prompt) : imported(flags); await preflight(io, context); - const port = await ghostPort(io, intent.mode, requestedPort); + const { port, mailpitPort } = await sitePorts( + io, + intent.mode, + requestedPort, + intent.services.includes('mailpit'), + ); heading(io, 'Resolving the Ghost image'); const ghost = await io.busy('Resolving the Ghost image', () => @@ -396,6 +427,7 @@ async function prepare( flags, intent, port, + mailpitPort, ghost, pin, project: projectName(intent.mode, intent.domain, dir), @@ -418,19 +450,28 @@ async function preflight(io: Io, context: Context): Promise { } /** - * The port Ghost is published on. Any port the site needs that a container - * already publishes is refused, naming the container. Where Docker would - * publish over a port a host process holds (io.hostListens), Ghost's port - * must also be free on the host: a chosen one skips it, a requested one is - * refused. + * The ports Ghost, and with --with mailpit Mailpit's inbox, are published on. + * Any port the site needs that a container already publishes is refused, + * naming the container. Where Docker would publish over a port a host process + * holds (io.hostListens), Ghost's port must also be free on the host: a + * chosen one skips it, a requested one is refused. Mailpit's is always + * chosen, the same way. */ -async function ghostPort(io: Io, mode: SiteMode, requested: number | undefined): Promise { +async function sitePorts( + io: Io, + mode: SiteMode, + requested: number | undefined, + mailpit: boolean, +): Promise<{ port: number; mailpitPort: number | null }> { // A stopped site's ports count as taken: it would fail to start again. const running = await listContainers(io.docker); const stopped = await stoppedSiteContainers(io.docker); const containers = [...running, ...stopped]; const published = new Set(containers.flatMap((container) => container.publishedPorts)); const port = requested ?? (await freeOnHost(io, published)); + const mailpitPort = mailpit + ? await freeOnHost(io, new Set([...published, port]), DEFAULT_MAILPIT_PORT) + : null; const wanted = [ port, ...(mode === 'production' ? [PRODUCTION_PORTS.http, PRODUCTION_PORTS.https] : []), @@ -464,14 +505,18 @@ async function ghostPort(io: Io, mode: SiteMode, requested: number | undefined): ' Nothing has been changed.', ); } - return port; + return { port, mailpitPort }; } /** The first port no container publishes and, where that can be told, nothing on the host holds. */ -async function freeOnHost(io: Io, published: ReadonlySet): Promise { +async function freeOnHost( + io: Io, + published: ReadonlySet, + start = DEFAULT_PORT, +): Promise { const taken = new Set(published); for (;;) { - const port = choosePort(taken); + const port = choosePort(taken, start); if (!(await io.hostListens(port))) { return port; } @@ -533,6 +578,9 @@ function writeEnv(site: Site, created: Created) { ['GHOST_CONTENT_PATH', ghost.contentPath], ['GHOST_TINYBIRD_PATH', ghost.tinybirdPath], ['GHOST_PORT', String(site.port)], + ...(site.mailpitPort === null + ? [] + : [['MAILPIT_PORT', String(site.mailpitPort)] as [string, string]]), ['RESTART_POLICY', production ? 'unless-stopped' : 'no'], ['DATABASE_HOST', 'db'], ['DATABASE_PORT', '3306'], @@ -570,14 +618,12 @@ async function writeGhostEnv( importing: Importing | null, ) { const path = join(dir, GHOST_ENV_FILE); - // The source site's configuration, without what the container owns. - const carried = await importing?.ghostEnv(); + const mailpit = intent.services.includes('mailpit'); + // The source site's configuration, without what the container owns, and + // with Mailpit, without the source's mail transport. + const carried = await importing?.ghostEnv(mailpit); created.file(path); - atomicWrite( - path, - ghostEnvTemplate(project, intent.services.length > 0) + (carried?.text ?? ''), - PRIVATE, - ); + atomicWrite(path, ghostEnvTemplate(project, intent.services) + (carried?.text ?? ''), PRIVATE); ok(io, GHOST_ENV_FILE, carried?.detail ?? 'Ghost application settings'); for (const { key, reason } of carried?.skipped ?? []) { printChecks(io, [{ status: 'note', label: 'not carried', detail: `${key}: ${reason}` }]); @@ -588,8 +634,9 @@ async function writeGhostEnv( * Bind mount sources must exist before the daemon resolves them, or it * creates them as root. Ownership inside is the images' own business. */ -function makeDataDirectories({ io, dir }: Site, created: Created, isImport: boolean) { - for (const data of DATA_DIRS) { +function makeDataDirectories({ io, dir, intent }: Site, created: Created, isImport: boolean) { + const dirs = [...DATA_DIRS, ...(intent.services.includes('mailpit') ? [MAILPIT_DATA_DIR] : [])]; + for (const data of dirs) { const path = join(dir, data); const before = created.directories.length; makeDirectories(path, created.directories); @@ -599,7 +646,7 @@ function makeDataDirectories({ io, dir }: Site, created: Created, isImport: bool } } created.save(); - ok(io, 'data', DATA_DIRS.join(' and ')); + ok(io, 'data', `${dirs.slice(0, -1).join(', ')} and ${dirs.at(-1)}`); } async function validateConfiguration({ io, dir, intent }: Site) { @@ -772,6 +819,9 @@ function printSummary(site: Site, importing: Importing | null) { ` Project ${site.project} (${site.profiles}), in ${site.dir}`, ` Ghost ${ghost.version}, ${ghost.reference}`, ` Loopback 127.0.0.1:${site.port}`, + ...(site.mailpitPort === null + ? [] + : [` Mailpit http://127.0.0.1:${site.mailpitPort}, the mail Ghost sends`]), ...(importing === null ? [] : [` Imported ${importing.description}`]), '', '.env and ghost.env hold the credentials; back them up.', @@ -789,20 +839,33 @@ const describe = (error: unknown) => /** * Written fresh rather than copied from the example, whose SMTP block is a * placeholder: a site shipping with smtp.example.com fails to send mail in a - * way that looks like a Ghost bug. + * way that looks like a Ghost bug. With Mailpit, mail goes to it; the + * operator owns these lines afterwards. */ -function ghostEnvTemplate(project: string, optionalServices: boolean): string { +export function ghostEnvTemplate(project: string, services: readonly string[]): string { + const mailpit = services.includes('mailpit'); return [ `# Ghost application settings for ${project}; see ghost.env.example.`, '# Write values with ./ghost-docker config set ghost.env KEY VALUE, which encodes', - '# them for Compose. Staff invites and password resets need mail__* (SMTP) set.', + mailpit + ? '# them for Compose. Mail goes to Mailpit, whose inbox is at MAILPIT_PORT in .env.' + : '# them for Compose. Staff invites and password resets need mail__* (SMTP) set.', '', - // Both optional services need Ghost's public API. - ...(optionalServices ? [env.serialize('labs__publicAPI', 'true')] : []), + // ActivityPub (and analytics, added later) need Ghost's public API. + ...(services.includes('activitypub') ? [env.serialize('labs__publicAPI', 'true')] : []), + ...(mailpit ? mailpitMail(project).map(([key, value]) => env.serialize(key, value)) : []), '', ].join('\n'); } +/** Ghost's SMTP settings for the site's own Mailpit, by its unique alias. */ +export const mailpitMail = (project: string): [string, string][] => [ + ['mail__transport', 'SMTP'], + ['mail__options__host', `mailpit-${project}`], + ['mail__options__port', '1025'], + ['mail__options__secure', 'false'], +]; + /** * Why `up` failed, in Compose's own words. Docker names a port it could not * bind; its wording changes between versions, so it is quoted, not parsed. diff --git a/manager/src/commands/site.ts b/manager/src/commands/site.ts index 8080dd4a..67052c1f 100644 --- a/manager/src/commands/site.ts +++ b/manager/src/commands/site.ts @@ -8,6 +8,7 @@ import { EXIT } from '../errors.ts'; import type { Io } from '../io.ts'; import { describeMetadata, readMetadata } from '../meta.ts'; import { failed, printChecks, type Check } from '../report.ts'; +import { hasProfile, readSettings } from '../site.ts'; import { verifyIngress } from '../verify.ts'; import { installedSite } from './common.ts'; import { collect } from './doctor.ts'; @@ -179,13 +180,20 @@ export const checkCommand = defineCommand({ // --- info --------------------------------------------------------------------- export const infoCommand = defineCommand({ - brief: 'Print the recorded installation metadata.', + brief: "Print the recorded installation metadata, and where Mailpit's inbox is.", run: async (_values, _positionals, io) => { const context = loadContext(io.env); const metadata = readMetadata(context.siteDir); for (const line of describeMetadata(metadata)) { io.stdout(`${line}\n`); } + // From `.env`, which says where it is now; the metadata records the install. + const settings = readSettings(context.siteDir); + if (settings && hasProfile(settings.get('COMPOSE_PROFILES') ?? '', 'mailpit')) { + io.stdout( + `mailpit http://127.0.0.1:${settings.get('MAILPIT_PORT') || '8025'}\n`, + ); + } return metadata.state === 'invalid' ? EXIT.failure : EXIT.ok; }, }); diff --git a/manager/src/config.ts b/manager/src/config.ts index 382f122f..9ce66d4d 100644 --- a/manager/src/config.ts +++ b/manager/src/config.ts @@ -26,7 +26,9 @@ import { ENV_EXAMPLE_FILE, ENV_FILE, GHOST_ENV_FILE, + hasProfile, hostOf, + LOCAL_ONLY_PROFILES, OPTIONAL_PROFILES, SITE_MODES, siteMode, @@ -130,6 +132,16 @@ export async function validateEnv(io: Io, dir: string): Promise { ), ); } + if (mode === 'production') { + for (const profile of LOCAL_ONLY_PROFILES.filter((each) => hasProfile(profiles, each))) { + findings.push( + error( + file, + `COMPOSE_PROFILES selects ${profile}, which is for local sites only: in production it would catch the site's real mail`, + ), + ); + } + } const declared = values.SITE_MODE; if (mode !== null && declared && declared !== mode) { findings.push( diff --git a/manager/src/import.ts b/manager/src/import.ts index c62426bd..f60078d1 100644 --- a/manager/src/import.ts +++ b/manager/src/import.ts @@ -16,7 +16,7 @@ import * as env from './env.ts'; import { CliError, UsageError } from './errors.ts'; import { atomicWrite, readIfExists } from './fs.ts'; import { resolveExactGhost, type ResolvedGhost } from './ghost.ts'; -import { sourceConfig, type CarriedConfig } from './import/config.ts'; +import { replaceMailTransport, sourceConfig, type CarriedConfig } from './import/config.ts'; import { BATCH, databaseInput, @@ -64,10 +64,16 @@ export function refuseImportOptions(flags: { ); } } - if (flags.with !== undefined) { + // Mailpit only replaces the mail transport; anything else would change + // what the imported site is. + const others = (flags.with ?? '') + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== '' && item !== 'mailpit'); + if (flags.with !== undefined && others.length > 0) { throw new UsageError( - '--with cannot be combined with --import. Import the site first, then enable\n' + - ' optional services; see docs/configuration.md.', + `--with ${others.join(',')} cannot be combined with --import; only mailpit can. Import the\n` + + ' site first, then enable optional services; see docs/configuration.md.', ); } } @@ -158,9 +164,18 @@ export class Importing { created.save(); } - /** The source site's configuration for ghost.env, without what the container owns. */ - async ghostEnv(): Promise<{ text: string; detail: string; skipped: CarriedConfig['skipped'] }> { - const { settings, skipped } = await sourceConfig(this.io, this.dir, this.manifest); + /** + * The source site's configuration for ghost.env, without what the + * container owns. With Mailpit, without the source's mail transport, which + * Mailpit replaces: a local copy of a site then never sends real mail. + */ + async ghostEnv( + mailpit: boolean, + ): Promise<{ text: string; detail: string; skipped: CarriedConfig['skipped'] }> { + const { settings, skipped } = replaceMailTransport( + await sourceConfig(this.io, this.dir, this.manifest), + mailpit, + ); return { text: settings.length === 0 diff --git a/manager/src/import/config.ts b/manager/src/import/config.ts index d345751a..4ffe2537 100644 --- a/manager/src/import/config.ts +++ b/manager/src/import/config.ts @@ -72,3 +72,23 @@ export async function sourceConfig( const container = new Set(Object.keys(resolved.project.services.ghost?.environment ?? {})); return carriedConfig(manifest, container, operatorKeyTest(dir)); } + +/** Ghost's mail transport: what Mailpit replaces. `mail__from` is the site's own. */ +export const isMailTransport = (key: string): boolean => + key === 'mail__transport' || key.startsWith('mail__options__'); + +/** With Mailpit, the carried configuration without the source's mail transport. */ +export function replaceMailTransport(carried: CarriedConfig, mailpit: boolean): CarriedConfig { + if (!mailpit) { + return carried; + } + return { + settings: carried.settings.filter(([key]) => !isMailTransport(key)), + skipped: [ + ...carried.skipped, + ...carried.settings + .filter(([key]) => isMailTransport(key)) + .map(([key]) => ({ key, reason: 'replaced by Mailpit (--with mailpit)' })), + ], + }; +} diff --git a/manager/src/site.ts b/manager/src/site.ts index 12c6f717..a415f4de 100644 --- a/manager/src/site.ts +++ b/manager/src/site.ts @@ -17,6 +17,9 @@ export const COMPOSE_OVERRIDE_FILE = 'compose.override.yml'; */ export const DATA_DIRS = [join('data', 'ghost'), join('data', 'mysql')] as const; +/** Mailpit's inbox, at compose.yml's default (MAILPIT_DATA_LOCATION); only a mailpit site has it. */ +export const MAILPIT_DATA_DIR = join('data', 'mailpit'); + /** The two files `config` reads and writes. Nothing else is an env file here. */ export const CONFIG_FILES = [ENV_FILE, GHOST_ENV_FILE] as const; export type ConfigFile = (typeof CONFIG_FILES)[number]; @@ -25,7 +28,13 @@ export const SITE_MODES = ['local', 'production'] as const; export type SiteMode = (typeof SITE_MODES)[number]; /** Additive, per-site. `supervisor` is reserved and defines no service yet. */ -export const OPTIONAL_PROFILES = ['analytics', 'activitypub', 'supervisor'] as const; +export const OPTIONAL_PROFILES = ['analytics', 'activitypub', 'mailpit', 'supervisor'] as const; + +/** + * Optional profiles a production site must not select: Mailpit would catch + * the site's real mail. + */ +export const LOCAL_ONLY_PROFILES = ['mailpit'] as const; /** `production, analytics` as a list: trimmed, empties dropped. */ export const splitProfiles = (profiles: string): string[] => diff --git a/manager/src/undo.ts b/manager/src/undo.ts index 2ec2656e..bae80603 100644 --- a/manager/src/undo.ts +++ b/manager/src/undo.ts @@ -16,7 +16,7 @@ import type { Written } from './payload.ts'; import { DATA_DIRS, ENV_FILE, GHOST_ENV_FILE, META_FILE } from './site.ts'; /** Every profile, so whatever a failed installation started is found. */ -export const ALL_PROFILES = 'local,production,analytics,activitypub'; +export const ALL_PROFILES = 'local,production,analytics,activitypub,mailpit'; const journalSchema = z.object({ files: z.array(z.string().startsWith('/')), diff --git a/manager/src/verify.ts b/manager/src/verify.ts index 1272e939..2612c2bd 100644 --- a/manager/src/verify.ts +++ b/manager/src/verify.ts @@ -11,11 +11,13 @@ // name it serves // https whether Caddy holds a certificate for the domain yet: // serving, or pending until DNS reaches this host +// mailpit with that profile: its health check, and from the ghost +// container, over the site's network, its SMTP greeting // published ports what Docker says it published, not verified from the host import { compose, composeError, composePs, type ServiceState } from './compose.ts'; import type { Io } from './io.ts'; import type { Check } from './report.ts'; -import type { SiteFacts } from './site.ts'; +import { hasProfile, type SiteFacts } from './site.ts'; /** * Run in the ghost container, which has Node and is on the site's network: @@ -71,6 +73,53 @@ async function redirects(io: Io, site: SiteFacts, domains: readonly string[]): P }); } +/** Run in the ghost container: the first line Mailpit's SMTP server sends. */ +const SMTP_PROBE = ` +const socket = require('net').connect({ host: process.argv[1], port: 1025, timeout: 10000 }); +socket.once('data', (data) => { console.log(String(data).split('\\r\\n')[0]); socket.destroy(); }); +socket.on('timeout', () => { console.log('no answer within 10 seconds'); socket.destroy(); }); +socket.on('error', (error) => console.log(error.message)); +`; + +/** Mailpit, healthy, and taking mail where Ghost sends it: its alias on the site network. */ +async function mailpit( + io: Io, + site: SiteFacts, + services: readonly ServiceState[] | null, +): Promise { + const state = services?.find((entry) => entry.Service === 'mailpit'); + if (state?.Health !== 'healthy') { + return [ + { + status: 'error', + label: 'mailpit', + detail: `not healthy (${state ? [state.State, state.Health].filter(Boolean).join(', ') : 'no container'})`, + }, + ]; + } + const host = `mailpit-${site.settings.get('COMPOSE_PROJECT_NAME') || 'ghost'}`; + const result = await compose( + io, + site.dir, + ['exec', '-T', 'ghost', 'node', '-e', SMTP_PROBE, host], + { timeoutMs: 60_000 }, + ); + const greeting = result.stdout.trim(); + return [ + result.exitCode === 0 && greeting.startsWith('220') + ? { + status: 'ok', + label: 'mailpit', + detail: `healthy, and takes mail at ${host}:1025 on the site network`, + } + : { + status: 'error', + label: 'mailpit', + detail: `did not answer SMTP at ${host}:1025 from the ghost container: ${greeting || composeError(result, 2) || 'no answer'}`, + }, + ]; +} + /** * HTTPS as an issuance state. Caddy obtains a certificate in the background * once the domain's DNS reaches this host and keeps it in its data volume; @@ -141,12 +190,17 @@ export async function verifyIngress( const domains = [site.domain, site.adminDomain].filter((name) => name !== ''); checks.push(...(await redirects(io, site, domains)), await https(io, site)); } + const withMailpit = hasProfile(site.settings.get('COMPOSE_PROFILES') ?? '', 'mailpit'); + if (withMailpit) { + checks.push(...(await mailpit(io, site, services))); + } checks.push({ status: 'note', label: 'published ports', detail: `Docker publishes Ghost on ${publishedPorts(services, 'ghost')}` + (production ? ` and Caddy on ${publishedPorts(services, 'caddy')}` : '') + + (withMailpit ? `, and Mailpit's inbox on ${publishedPorts(services, 'mailpit')}` : '') + '; a container cannot reach the host, so open the URL to see them from there', }); return checks; diff --git a/manager/test/config.test.ts b/manager/test/config.test.ts index b5b9380b..72a02844 100644 --- a/manager/test/config.test.ts +++ b/manager/test/config.test.ts @@ -48,6 +48,15 @@ describe('.env', () => { assert.doesNotMatch(result.errors, /: supervisor/); }); + test('Mailpit is for local sites only', async () => { + makeSite(h, { ...LOCAL, COMPOSE_PROFILES: 'local,mailpit' }); + assert.equal((await validate()).code, 0); + makeSite(h, { ...PRODUCTION, COMPOSE_PROFILES: 'production,mailpit' }); + const result = await validate(); + assert.equal(result.code, 1); + assert.match(result.errors, /selects mailpit, which is for local sites only/); + }); + test('SITE_MODE must match COMPOSE_PROFILES', async () => { makeSite(h, { ...LOCAL, SITE_MODE: 'production' }); assert.match((await validate()).errors, /SITE_MODE=production does not match .* \(local\)/); diff --git a/manager/test/import.test.ts b/manager/test/import.test.ts index 8ca569bc..89448f8b 100644 --- a/manager/test/import.test.ts +++ b/manager/test/import.test.ts @@ -389,6 +389,36 @@ describe('importing a local site', () => { assert.doesNotMatch(result.stdout + result.stderr, /pa\$\$word/); }); + test('with Mailpit, the source mail transport is replaced and its sender kept', async () => { + const result = await install( + '--import', + bundle(local('mysql-data')), + '--with', + 'mailpit', + '--no-start', + ); + assert.equal(result.code, 0, result.stderr); + assert.equal(setting('COMPOSE_PROFILES'), 'local,mailpit'); + assert.equal(setting('MAILPIT_PORT'), '8025'); + assert.equal(ghostSetting('mail__options__auth__pass'), undefined); + assert.equal( + ghostSetting('mail__options__host'), + `mailpit-${setting('COMPOSE_PROJECT_NAME')}`, + ); + assert.equal(ghostSetting('mail__options__port'), '1025'); + assert.equal(ghostSetting('mail__transport'), 'SMTP'); + assert.equal(ghostSetting('mail__from'), 'Ghost Blog '); + assert.match( + result.stdout, + /note +not carried +mail__options__auth__pass: replaced by Mailpit/, + ); + // Each key once: the source's never follows Mailpit's. + const keys = env.keys(readFileSync(join(h.dir, 'ghost.env'), 'utf8')); + assert.equal(keys.length, new Set(keys).size); + assert.ok(existsSync(join(h.dir, 'data', 'mailpit'))); + assert.match(result.stdout, /Mailpit +http:\/\/127\.0\.0\.1:8025/); + }); + test('a mysql-dump bundle: an empty database, the dump, a migration history', async () => { const result = await install('--import', bundle(local('mysql-dump')), '--no-start'); assert.equal(result.code, 0, result.stderr); @@ -448,9 +478,9 @@ describe('refusals that change nothing', () => { /--domain cannot be combined with --import.*\n.*S5e/, ); await refusedWith( - ['--import', path, '--with', 'activitypub'], + ['--import', path, '--with', 'mailpit,activitypub'], 2, - /--with cannot be combined/, + /--with activitypub cannot be combined with --import; only mailpit can/, ); await refusedWith(['--import', ''], 2, /--import needs the path/); await refusedWith( diff --git a/manager/test/install.test.ts b/manager/test/install.test.ts index baa2da9b..ec066392 100644 --- a/manager/test/install.test.ts +++ b/manager/test/install.test.ts @@ -102,6 +102,7 @@ describe('refusals that change nothing', () => { [['--domain', 'example.com', '--email', 'nobody'], /--email must be an email address/], [['--domain', 'example.com', '--admin-domain', 'example.com'], /must differ/], [['--local', '--with', 'redis'], /unknown optional service: redis/], + [['--domain', 'example.com', '--with', 'mailpit'], /mailpit is for local sites only/], [['--local', '--with', 'production'], /--with selects optional services/], [['--local', '--port', '70000'], /port number/], [['--local', '--frobnicate'], /frobnicate/], @@ -263,6 +264,36 @@ describe('refusals that change nothing', () => { assert.equal(env.get(readFileSync(join(h.dir, '.env'), 'utf8'), 'GHOST_PORT'), '2368'); }); + test('existing Mailpit data is never installed over either', async () => { + mkdirSync(join(h.dir, 'data', 'mailpit'), { recursive: true }); + writeFileSync(join(h.dir, 'data', 'mailpit', 'mailpit.db'), ''); + const result = await install('--local', '--with', 'mailpit'); + assert.match(result.stderr, /data\/mailpit is not empty/); + }); + + test("Mailpit's port skips those a container publishes, a host process holds, and Ghost's", async () => { + h.daemon.containers = [ + { + Id: 'o', + Names: ['/other-mailpit-1'], + Ports: [{ PrivatePort: 8025, PublicPort: 8025, Type: 'tcp' }], + }, + ]; + h.daemon.hostPorts = [8026]; + const result = await install( + '--local', + '--port', + '8027', + '--with', + 'mailpit', + '--no-start', + ); + assert.equal(result.code, 0, result.stderr); + const values = env.toRecord(readFileSync(join(h.dir, '.env'), 'utf8')); + assert.equal(values.GHOST_PORT, '8027'); + assert.equal(values.MAILPIT_PORT, '8028'); + }); + test('a failed preflight changes nothing', async () => { h.daemon.info = { ServerVersion: '24.0.0', OSType: 'linux', Architecture: 'x86_64' }; const result = await install('--local'); @@ -358,6 +389,40 @@ describe('a local site, not started', () => { }); }); +describe('a local site with Mailpit, not started', () => { + let result: Awaited>; + beforeEach(async () => { + result = await install('--local', '--with', 'mailpit', '--no-start'); + assert.equal(result.code, 0, result.stderr); + }); + + test('.env selects it and publishes its inbox on a port of its own', () => { + const values = env.toRecord(readFileSync(join(h.dir, '.env'), 'utf8')); + assert.equal(values.COMPOSE_PROFILES, 'local,mailpit'); + assert.equal(values.GHOST_PORT, '2368'); + assert.equal(values.MAILPIT_PORT, '8025'); + }); + + test('ghost.env sends mail to it by its unique alias, and nothing else is added', () => { + const values = env.toRecord(readFileSync(join(h.dir, 'ghost.env'), 'utf8')); + const project = env.get(readFileSync(join(h.dir, '.env'), 'utf8'), 'COMPOSE_PROJECT_NAME'); + assert.deepEqual(values, { + mail__transport: 'SMTP', + mail__options__host: `mailpit-${project}`, + mail__options__port: '1025', + mail__options__secure: 'false', + }); + }); + + test('its inbox has a data directory, and the summary says where it is', () => { + assert.ok(statSync(join(h.dir, 'data', 'mailpit')).isDirectory()); + assert.match(result.stdout, /data\/ghost, data\/mysql and data\/mailpit/); + assert.match(result.stdout, /Mailpit +http:\/\/127\.0\.0\.1:8025/); + const metadata = JSON.parse(readFileSync(join(h.dir, '.ghost-docker.json'), 'utf8')); + assert.deepEqual(metadata.profiles, ['local', 'mailpit']); + }); +}); + describe('a production site, not started', () => { test('its routes are written once, with the ACME account, and nothing is run to do it', async () => { const result = await install( diff --git a/manager/test/meta.test.ts b/manager/test/meta.test.ts index 5cebdf52..0ca0ba96 100644 --- a/manager/test/meta.test.ts +++ b/manager/test/meta.test.ts @@ -10,7 +10,9 @@ import { writeMetadata, type Metadata, } from '../src/meta.ts'; +import { join } from 'node:path'; import { harness, type Harness } from './helpers.ts'; +import { writeEnvFile } from './site.ts'; let h: Harness; beforeEach(() => { @@ -68,6 +70,15 @@ describe('installation metadata', () => { assert.match(info.stdout, new RegExp(`ghost image +ghost@${DIGEST}`)); }); + test('with Mailpit, info says where its inbox is, from .env', async () => { + writeMetadata(h.dir, sample(h.dir)); + writeEnvFile(join(h.dir, '.env'), { + COMPOSE_PROFILES: 'local,mailpit', + MAILPIT_PORT: '8026', + }); + assert.match((await h.run('info')).stdout, /^mailpit +http:\/\/127\.0\.0\.1:8026$/m); + }); + test('the same document is written as the same bytes', () => { writeMetadata(h.dir, sample(h.dir)); const first = readFileSync(metaPath(h.dir), 'utf8'); diff --git a/manager/test/verify.test.ts b/manager/test/verify.test.ts index 1b185731..4aafb64d 100644 --- a/manager/test/verify.test.ts +++ b/manager/test/verify.test.ts @@ -7,13 +7,15 @@ import { failed, harness, ok, type Harness } from './helpers.ts'; import { LOCAL, makeSite, PRODUCTION } from './site.ts'; let h: Harness; -let site: { ghost: string; redirect: string; certificate: boolean }; +let site: { ghost: string; redirect: string; certificate: boolean; mailpit: string; smtp: string }; beforeEach(() => { h = harness(); site = { ghost: 'healthy', redirect: '308 https://example.com/', certificate: false, + mailpit: 'healthy', + smtp: '220 a1b2c3 Mailpit ESMTP Service ready', }; h.daemon.composeRun = (args) => { if (args[0] === 'ps') { @@ -30,13 +32,19 @@ beforeEach(() => { State: 'running', Publishers: [{ URL: '0.0.0.0', PublishedPort: 80 }], }, + { + Service: 'mailpit', + State: 'running', + Health: site.mailpit, + Publishers: [{ URL: '127.0.0.1', PublishedPort: 8025 }], + }, ] .map((entry) => JSON.stringify(entry)) .join('\n'), ); } if (args[0] === 'exec' && args[2] === 'ghost') { - return ok(`${site.redirect}\n`); + return ok(`${args.at(-1)?.startsWith('mailpit-') ? site.smtp : site.redirect}\n`); } if (args[0] === 'exec' && args[2] === 'caddy') { return site.certificate @@ -96,3 +104,27 @@ test('a local site has no Caddy to ask', async () => { const checks = await verify(LOCAL); assert.deepEqual(Object.keys(checks), ['ghost', 'published ports']); }); + +describe('a local site with Mailpit', () => { + const MAILPIT = { ...LOCAL, COMPOSE_PROFILES: 'local,mailpit' }; + + test('healthy, it takes mail at its alias, and its inbox port is reported', async () => { + const checks = await verify(MAILPIT); + assert.equal(checks.mailpit!.status, 'ok'); + assert.match(checks.mailpit!.detail, /takes mail at mailpit-ghost-local-site:1025/); + assert.match( + checks['published ports']!.detail, + /Ghost on 127\.0\.0\.1:2368, and Mailpit's inbox on 127\.0\.0\.1:8025/, + ); + }); + + test('unhealthy, or not answering SMTP, it is an error', async () => { + site.mailpit = 'unhealthy'; + assert.match((await verify(MAILPIT)).mailpit!.detail, /not healthy \(running, unhealthy\)/); + site.mailpit = 'healthy'; + site.smtp = 'connect ECONNREFUSED'; + const checks = await verify(MAILPIT); + assert.equal(checks.mailpit!.status, 'error'); + assert.match(checks.mailpit!.detail, /did not answer SMTP .*ECONNREFUSED/); + }); +}); diff --git a/tests/e2e/import.sh b/tests/e2e/import.sh index 2ec385ed..17f63ec7 100755 --- a/tests/e2e/import.sh +++ b/tests/e2e/import.sh @@ -269,15 +269,16 @@ export_source() { >"$output.export.log" 2>&1 || fail "ghost migrate-export failed" "$(tail -40 "$output.export.log")" } -# expect_imported SITE SLUG TITLE +# expect_imported SITE SLUG TITLE [PROFILES] # Everything that must be true of a running site imported from a source. +# PROFILES is what .env selects once the import is done; local by default. expect_imported() { - local site=$1 slug=$2 title=$3 port base jar body value + local site=$1 slug=$2 title=$3 profiles=${4:-local} port base jar body value port=$(setting "$site" GHOST_PORT) base="http://localhost:$port" jar=$site.cookies - [[ $(setting "$site" COMPOSE_PROFILES) == local ]] || fail "COMPOSE_PROFILES is not local" + [[ $(setting "$site" COMPOSE_PROFILES) == "$profiles" ]] || fail "COMPOSE_PROFILES is not $profiles" [[ ! -e $site/.ghost-docker-import ]] || fail "the import marker was left behind" [[ ! -e $site/.import ]] || fail "the staging directory was left behind" [[ $(jq -r .ghost.version "$site/.ghost-docker.json") == "$GHOST_VERSION" ]] || @@ -392,6 +393,28 @@ compose_in "$site" up --detach --wait --wait-timeout 600 >/dev/null 2>&1 || fail expect_imported "$site" "$SQLITE_SLUG" "SQLite source" compose_in "$site" down >/dev/null 2>&1 +step "With --with mailpit, the source's mail transport is replaced, and its mail is caught" +destination with-mailpit +run_import "$site" --import "$UNPACKED" --port "$port" --with mailpit +expect_success "the import" +expect_output 'not carried +mail__transport: replaced by Mailpit' +expect_imported "$site" "$SQLITE_SLUG" "SQLite source" local,mailpit +[[ $(compose_in "$site" exec -T ghost printenv mail__options__host) == "mailpit-$(setting "$site" COMPOSE_PROJECT_NAME)" ]] || + fail "Ghost does not send mail to Mailpit" +status=$(http_status "http://127.0.0.1:$port/ghost/api/admin/authentication/password_reset/" --request POST \ + --header 'Content-Type: application/json' --header "Origin: http://localhost:$port" \ + --data "$(jq -cn --arg e "$OWNER_EMAIL" '{password_reset: [{email: $e}]}')") +[[ $status == 200 ]] || fail "the password reset answered $status" +inbox=http://127.0.0.1:$(setting "$site" MAILPIT_PORT)/api/v1/messages +for _ in $(seq 1 30); do + curl --disable --silent --noproxy '*' --max-time 10 "$inbox" | jq -e '.messages[] | select(.Subject == "Reset Password")' >/dev/null && break + sleep 1 +done +curl --disable --silent --noproxy '*' --max-time 10 "$inbox" | jq -e '.messages[] | select(.Subject == "Reset Password")' >/dev/null || + fail "the reset mail never reached Mailpit" "$(curl --disable --silent --noproxy '*' "$inbox")" +ok "mail__from carried over, and the imported site's mail goes to Mailpit" +compose_in "$site" down >/dev/null 2>&1 + step "Row counts that disagree with the bundle fail the import" tampered=$WORK/bundle-tampered-rows cp -R "$UNPACKED" "$tampered" diff --git a/tests/e2e/install.sh b/tests/e2e/install.sh index d8b72f72..756c5131 100755 --- a/tests/e2e/install.sh +++ b/tests/e2e/install.sh @@ -375,6 +375,45 @@ expect_status 0 ok "the analytics helper images build from the tinybird/ written there" compose_in "$AP" down --volumes >/dev/null 2>&1 +step "Mailpit catches the mail a local site sends" +new_site e2e-mailpit +M=$SITE +run install_alone "$M" --local --with mailpit +expect_status 0 +expect_output 'ok +mailpit +healthy, and takes mail at mailpit-ghost-local-e2e-mailpit:1025' +expect_output 'Mailpit +http://127\.0\.0\.1:[0-9]+' +inbox=$(setting "$M" MAILPIT_PORT) +[[ $(setting "$M" mail__options__host) == mailpit-ghost-local-e2e-mailpit ]] || fail "ghost.env does not send mail to Mailpit" +bindings=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{range $b}}{{.HostIp}} {{end}}{{end}}' "$(compose_in "$M" ps -q mailpit)") +[[ $bindings == "127.0.0.1 " ]] || fail "Mailpit is published on: $bindings" +ok "installed, verified, and the inbox is published on 127.0.0.1:$inbox only" +# The owner, then a password reset: Ghost sends both through SMTP. +ghost_url=http://127.0.0.1:$(setting "$M" GHOST_PORT)/ghost/api/admin/authentication +admin_post() { + curl --silent --noproxy '*' --max-time 60 --output /dev/null --write-out '%{http_code}' \ + --header 'Content-Type: application/json' --header "Origin: $(setting "$M" URL)" \ + --data "$2" "$ghost_url/$1/" || true +} +status=$(admin_post setup "{\"setup\":[{\"name\":\"E2E\",\"email\":\"owner@example.com\",\"password\":\"$(openssl rand -hex 16)\",\"blogTitle\":\"E2E\"}]}") +[[ $status == 201 ]] || fail "owner setup answered $status" +status=$(admin_post password_reset '{"password_reset":[{"email":"owner@example.com"}]}') +[[ $status == 200 ]] || fail "the password reset answered $status" +subjects="" +for _ in $(seq 1 30); do + subjects=$(curl --silent --noproxy '*' --max-time 10 "http://127.0.0.1:$inbox/api/v1/messages" | grep -oE '"Subject":"[^"]*"' || true) + grep -q '"Subject":"Reset Password"' <<<"$subjects" && break + sleep 1 +done +grep -q '"Subject":"Reset Password"' <<<"$subjects" || fail "the reset mail never reached Mailpit" "$subjects" +ok "Ghost's password-reset mail arrived in Mailpit's inbox" +compose_in "$M" restart mailpit >/dev/null 2>&1 +compose_in "$M" up --detach --wait mailpit >/dev/null 2>&1 +curl --silent --noproxy '*' --max-time 10 "http://127.0.0.1:$inbox/api/v1/messages" | grep -q '"Subject":"Reset Password"' || + fail "the inbox did not survive a restart" +[[ -f $M/data/mailpit/mailpit.db ]] || fail "the inbox is not kept in data/mailpit" +ok "the inbox is kept in data/mailpit, across a restart" +compose_in "$M" down --volumes >/dev/null 2>&1 + # --- From a clone --------------------------------------------------------------- step "A local and a production site from a clone of the repository"