diff --git a/src/commands/open.tsx b/src/commands/open.tsx index ed11e57..440e768 100644 --- a/src/commands/open.tsx +++ b/src/commands/open.tsx @@ -25,6 +25,7 @@ export const args = zod.tuple([ ' wp, wordpress, web Open the site\n' + ' admin, wpadmin, dashboard Open the WordPress dashboard (auto-login)\n' + ' phpmyadmin, pma Open phpMyAdmin (auto-login)\n' + + ' mail, mailpit Open the Mailpit email catcher\n' + ' plugins Open the plugins folder\n' + ' uploads, media Open the uploads folder\n' + ' data Open the Kiqr project data folder', @@ -42,6 +43,8 @@ const ALIASES: Record = { web: 'wp', phpmyadmin: 'phpmyadmin', pma: 'phpmyadmin', + mail: 'mail', + mailpit: 'mail', admin: 'admin', wpadmin: 'admin', dashboard: 'admin', @@ -110,6 +113,11 @@ export default function Open({args}: Props) { case 'phpmyadmin': openTarget(`http://${phpMyAdminHostname}:5477`); break; + case 'mail': + // Mailpit is an agent-level service shared by every project, reached + // at a fixed host rather than a per-project hostname. + openTarget('http://mail.lvh.me:5477'); + break; case 'admin': if (lc?.login_secret) { openTarget(`http://${hostname}:5477/wp-admin?kiqr_login=${lc.login_secret}`); diff --git a/src/commands/restart.tsx b/src/commands/restart.tsx index 382bcba..342e357 100644 --- a/src/commands/restart.tsx +++ b/src/commands/restart.tsx @@ -5,6 +5,7 @@ import {useRef, useState} from 'react'; import type {Step} from '../components/StepRunner.js'; import StepRunner from '../components/StepRunner.js'; import {ensureAgentRunning} from '../lib/agent.js'; +import {writeApacheConf} from '../lib/apache-conf.js'; import {writeProjectCompose} from '../lib/compose.js'; import {readLocalConfig, readProjectConfig, writeLocalConfig} from '../lib/config.js'; import { @@ -16,7 +17,6 @@ import { import {buildProjectHostname} from '../lib/hostname.js'; import {validateWordPressPhp} from '../lib/image-tags.js'; import {writeMuPlugin} from '../lib/mu-plugin.js'; -import {writeApacheConf} from '../lib/apache-conf.js'; import { getProjectPluginsDir, getProjectRuntimeDir, diff --git a/src/commands/up.tsx b/src/commands/up.tsx index 04809e0..11e4343 100644 --- a/src/commands/up.tsx +++ b/src/commands/up.tsx @@ -7,6 +7,7 @@ import {useRef, useState} from 'react'; import type {Step} from '../components/StepRunner.js'; import StepRunner from '../components/StepRunner.js'; import {ensureAgentRunning} from '../lib/agent.js'; +import {writeApacheConf} from '../lib/apache-conf.js'; import {writeProjectCompose} from '../lib/compose.js'; import { projectConfigExists, @@ -24,7 +25,6 @@ import { import {buildProjectHostname} from '../lib/hostname.js'; import {validateWordPressPhp} from '../lib/image-tags.js'; import {writeMuPlugin} from '../lib/mu-plugin.js'; -import {writeApacheConf} from '../lib/apache-conf.js'; import { getProjectPluginsDir, getProjectRuntimeDir, diff --git a/src/lib/agent.ts b/src/lib/agent.ts index f1c9365..c5b01a5 100644 --- a/src/lib/agent.ts +++ b/src/lib/agent.ts @@ -26,10 +26,19 @@ export const KIQR_NETWORK = 'kiqr'; export const AGENT_PROJECT = 'kiqr-agent'; export const AGENT_PORT = 5477; -export const AGENT_CONTAINERS = ['kiqr-traefik', 'kiqr-splash'] as const; +export const AGENT_CONTAINERS = ['kiqr-traefik', 'kiqr-splash', 'kiqr-mailpit'] as const; const TRAEFIK_CONTAINER = 'kiqr-traefik'; const SPLASH_CONTAINER = 'kiqr-splash'; +const MAILPIT_CONTAINER = 'kiqr-mailpit'; + +/** + * Mailpit catches every outgoing email from any kiqr project (WordPress is + * pointed at it over SMTP via the per-project mu-plugin) and exposes a web UI + * at `mail.lvh.me`. It listens for SMTP on 1025 and serves its web UI on 8025. + */ +const MAILPIT_HOST = 'mail.lvh.me'; +const MAILPIT_WEB_PORT = 8025; export interface AgentStatus { running: boolean; @@ -91,6 +100,18 @@ export function generateAgentCompose(agentDir: string): string { networks: [KIQR_NETWORK], restart: 'unless-stopped', }, + mailpit: { + image: 'axllent/mailpit:v1.30.1', + container_name: MAILPIT_CONTAINER, + labels: [ + 'traefik.enable=true', + `traefik.http.routers.kiqr-mailpit.rule=Host(\`${MAILPIT_HOST}\`)`, + 'traefik.http.routers.kiqr-mailpit.entrypoints=web', + `traefik.http.services.kiqr-mailpit.loadbalancer.server.port=${MAILPIT_WEB_PORT}`, + ], + networks: [KIQR_NETWORK], + restart: 'unless-stopped', + }, }, networks: { [KIQR_NETWORK]: { diff --git a/src/lib/mu-plugin.ts b/src/lib/mu-plugin.ts index 3a5cf72..16d1a4e 100644 --- a/src/lib/mu-plugin.ts +++ b/src/lib/mu-plugin.ts @@ -76,6 +76,19 @@ add_action('init', function () { } }, 0); +// Route all outgoing mail to the kiqr agent's Mailpit catcher over SMTP. +// The WordPress container shares the kiqr network, so it resolves the +// "kiqr-mailpit" container by name. Mailpit accepts unauthenticated, +// unencrypted SMTP on port 1025. +add_action('phpmailer_init', function ($phpmailer) { + $phpmailer->isSMTP(); + $phpmailer->Host = 'kiqr-mailpit'; + $phpmailer->Port = 1025; + $phpmailer->SMTPAuth = false; + $phpmailer->SMTPSecure = ''; + $phpmailer->SMTPAutoTLS = false; +}); + // Inject LiveReload script in development. // // The script src points at http://localhost on the host machine, which is only diff --git a/src/lib/share.ts b/src/lib/share.ts index f13ba98..6b42cb6 100644 --- a/src/lib/share.ts +++ b/src/lib/share.ts @@ -21,8 +21,7 @@ export const SHARE_URL_CONTAINER_PATH = '/tmp/kiqr-share-url'; export function resolveProjectContainerId( projectId: string, service: string, - exec: (cmd: string) => string = (cmd) => - execSync(cmd, {stdio: 'pipe'}).toString(), + exec: (cmd: string) => string = (cmd) => execSync(cmd, {stdio: 'pipe'}).toString(), ): string | null { try { const out = exec( @@ -126,13 +125,13 @@ export function writeShareUrlToContainer( } const id = resolveProjectContainerId(projectId, 'wordpress'); if (!id) { - onError?.(new Error(`Could not resolve WordPress container for project ${projectId}`)); + onError?.( + new Error(`Could not resolve WordPress container for project ${projectId}`), + ); return; } try { - exec( - `docker exec ${id} sh -c 'printf %s ${url} > ${SHARE_URL_CONTAINER_PATH}'`, - ); + exec(`docker exec ${id} sh -c 'printf %s ${url} > ${SHARE_URL_CONTAINER_PATH}'`); } catch (err) { onError?.(err); } diff --git a/src/providers/BitnamiRuntimeProvider.ts b/src/providers/BitnamiRuntimeProvider.ts index b815f26..3f823b1 100644 --- a/src/providers/BitnamiRuntimeProvider.ts +++ b/src/providers/BitnamiRuntimeProvider.ts @@ -81,15 +81,15 @@ export class BitnamiRuntimeProvider implements RuntimeProvider { "$$fwd_host = !empty($$_SERVER['HTTP_X_FORWARDED_HOST']) ? trim(explode(',', $$_SERVER['HTTP_X_FORWARDED_HOST'])[0]) : ''; " + "$$fwd_proto = !empty($$_SERVER['HTTP_X_FORWARDED_PROTO']) ? strtolower(trim(explode(',', $$_SERVER['HTTP_X_FORWARDED_PROTO'])[0])) : ''; " + "$$share_url = ($$fwd_proto === 'https' && is_readable('/tmp/kiqr-share-url')) ? trim(@file_get_contents('/tmp/kiqr-share-url')) : ''; " + - "$$share_parts = $$share_url ? parse_url($$share_url) : false; " + + '$$share_parts = $$share_url ? parse_url($$share_url) : false; ' + "if (is_array($$share_parts) && !empty($$share_parts['host'])) { " + " $$host = $$share_parts['host']; " + " $$proto = strtolower($$share_parts['scheme'] ?? 'https'); " + " $$_SERVER['HTTP_HOST'] = $$host; " + - "} else { " + + '} else { ' + " $$host = $$fwd_host ?: ($$_SERVER['HTTP_HOST'] ?? 'localhost'); " + " $$proto = ($$fwd_proto === 'https') ? 'https' : 'http'; " + - "} " + + '} ' + "if ($$proto === 'https') { $$_SERVER['HTTPS'] = 'on'; $$_SERVER['SERVER_PORT'] = 443; } " + "define('WP_HOME', $$proto . '://' . $$host); " + "define('WP_SITEURL', $$proto . '://' . $$host);", diff --git a/tests/lib/agent.test.ts b/tests/lib/agent.test.ts index aae396d..f6d2d3c 100644 --- a/tests/lib/agent.test.ts +++ b/tests/lib/agent.test.ts @@ -62,9 +62,7 @@ describe('generateAgentCompose', () => { const parsed = YAML.parse(yaml); const command = parsed.services.traefik.command as string[]; expect( - command.some( - (c) => c === '--entrypoints.web.forwardedHeaders.insecure=true', - ), + command.some((c) => c === '--entrypoints.web.forwardedHeaders.insecure=true'), ).toBe(true); }); @@ -76,6 +74,30 @@ describe('generateAgentCompose', () => { const labels = parsed.services.splash.labels; expect(labels.some((l: string) => l.includes('priority=1'))).toBe(true); }); + + it('includes the Mailpit email catcher with a pinned image', () => { + const yaml = generateAgentCompose('/tmp/kiqr/agent'); + const parsed = YAML.parse(yaml); + expect(parsed.services.mailpit).toBeDefined(); + expect(parsed.services.mailpit.image).toBe('axllent/mailpit:v1.30.1'); + expect(parsed.services.mailpit.container_name).toBe('kiqr-mailpit'); + expect(parsed.services.mailpit.networks).toContain('kiqr'); + expect(parsed.services.mailpit.restart).toBe('unless-stopped'); + }); + + it('routes mail.lvh.me to the Mailpit web UI on port 8025', () => { + const yaml = generateAgentCompose('/tmp/kiqr/agent'); + const parsed = YAML.parse(yaml); + const labels: string[] = parsed.services.mailpit.labels; + expect(labels).toContain('traefik.enable=true'); + expect(labels).toContain( + 'traefik.http.routers.kiqr-mailpit.rule=Host(`mail.lvh.me`)', + ); + expect(labels).toContain('traefik.http.routers.kiqr-mailpit.entrypoints=web'); + expect(labels).toContain( + 'traefik.http.services.kiqr-mailpit.loadbalancer.server.port=8025', + ); + }); }); describe('getAgentStatus', () => { diff --git a/tests/lib/mu-plugin.test.ts b/tests/lib/mu-plugin.test.ts index ed772d4..62c6978 100644 --- a/tests/lib/mu-plugin.test.ts +++ b/tests/lib/mu-plugin.test.ts @@ -101,7 +101,7 @@ describe('writeMuPlugin', () => { // production concern; in dev we want pages to render wherever requested. const content = fs.readFileSync(writeMuPlugin(tmp), 'utf-8'); expect(content).toContain("remove_filter('template_redirect', 'redirect_canonical')"); - expect(content).toContain("WPSEO_Frontend"); + expect(content).toContain('WPSEO_Frontend'); }); it('rewrites absolute production URLs in the response body to the current dynamic URL', () => { @@ -121,6 +121,15 @@ describe('writeMuPlugin', () => { expect(content).toContain('Content-Type:'); }); + it('routes outgoing mail to the kiqr-mailpit SMTP catcher via phpmailer_init', () => { + const content = fs.readFileSync(writeMuPlugin(tmp), 'utf-8'); + expect(content).toContain("add_action('phpmailer_init'"); + expect(content).toContain('$phpmailer->isSMTP()'); + expect(content).toContain("$phpmailer->Host = 'kiqr-mailpit'"); + expect(content).toContain('$phpmailer->Port = 1025'); + expect(content).toContain('$phpmailer->SMTPAuth = false'); + }); + it('is deterministic across writes', () => { const first = fs.readFileSync(writeMuPlugin(tmp), 'utf-8'); const second = fs.readFileSync(writeMuPlugin(tmp), 'utf-8'); diff --git a/tests/lib/share.test.ts b/tests/lib/share.test.ts index deb5bd7..1797f7d 100644 --- a/tests/lib/share.test.ts +++ b/tests/lib/share.test.ts @@ -150,7 +150,7 @@ describe('writeShareUrlToContainer', () => { }); describe('clearShareUrlFromContainer', () => { - it('is a no-op when no matching container is running (doesn\'t throw)', () => { + it("is a no-op when no matching container is running (doesn't throw)", () => { // Same rationale as writeShareUrlToContainer: when the container is // already gone there is nothing to clean and we mustn't fail the // tunnel-exit path.