From 51ea8718e706bba306ca17c524e5a0e7e54b52d6 Mon Sep 17 00:00:00 2001 From: Leonid Shevtsov Date: Thu, 30 Jul 2026 11:43:04 +0300 Subject: [PATCH] test: CI SMTP send+confirm smoke for binary and Docker Shared scripts/smoke-smtp.sh; wire into build and both docker boots so a broken SMTP ingest path fails CI. Co-authored-by: Cursor --- .github/workflows/ci.yml | 25 +++++--- scripts/smoke-smtp.ts | 130 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 scripts/smoke-smtp.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 337a2fa..46b2913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,11 +3,10 @@ name: ci # Runs on every push and PR. Four jobs that run in parallel: # - go — golangci-lint, go vet, go test (every package, race detector on) # - frontend — TypeScript build + ESLint + vitest -# - build — `scripts/build.sh` smoke (proves the embed pipeline still works) -# - docker — builds Dockerfile + Dockerfile.goreleaser and boots each -# on DEFAULT CMD (proves `docker run ` works out of the box; -# the raw-binary smoke can't catch image-level regressions like a -# bad CMD or a root-owned VOLUME mount point) +# - build — `scripts/build.sh` smoke + SMTP send/confirm +# - docker — builds Dockerfile + Dockerfile.goreleaser, boots each on +# DEFAULT CMD, then SMTP send/confirm (catches bad CMD, root-owned +# VOLUME mount, or broken SMTP ingest path) # # OpenAPI drift is covered by the `go` job — internal/api/openapi_drift_test.go # walks the live router and fails if anything's out of sync with the spec. @@ -99,7 +98,7 @@ jobs: - name: smoke (--version) run: ./bin/mailtrap-local --version - - name: smoke (boot + curl) + - name: smoke (boot + curl + SMTP) run: | ./bin/mailtrap-local --http-listen 127.0.0.1:13550 --smtp-listen 127.0.0.1:13535 --db :memory: & PID=$! @@ -107,6 +106,8 @@ jobs: for _ in 1 2 3 4 5; do sleep 0.4; curl -sf http://127.0.0.1:13550/api/v1/messages && break; done curl -sf http://127.0.0.1:13550/api/v1/openapi.yaml | head -1 | grep -q '^openapi:' curl -sf http://127.0.0.1:13550/ | grep -q '
-) + * FROM — envelope/header From (default: from@example.com) + * TO — envelope/header To (default: to@example.com) + * + * Run: node --experimental-strip-types scripts/smoke-smtp.ts + */ +import { spawnSync } from 'node:child_process' +import { setTimeout as sleep } from 'node:timers/promises' + +type MessagesResponse = { + messages?: Array<{ subject?: string }> +} + +function requireEnv(name: string): string { + const value = process.env[name] + if (!value) { + throw new Error(`${name} is required`) + } + return value +} + +async function waitForApi(httpBase: string): Promise { + const url = `${httpBase}/api/v1/messages` + console.log(`[smoke-smtp] waiting for API at ${httpBase}`) + for (let i = 0; i < 40; i++) { + try { + const res = await fetch(url) + if (res.ok) return + } catch { + // not ready yet + } + await sleep(250) + } + throw new Error(`API not ready at ${url}`) +} + +function smtpSendViaCurl(opts: { + smtpUrl: string + from: string + to: string + subject: string +}): void { + const { smtpUrl, from, to, subject } = opts + console.log(`[smoke-smtp] sending via ${smtpUrl} subject=${subject}`) + + const mail = + `From: ${from}\r\n` + + `To: ${to}\r\n` + + `Subject: ${subject}\r\n` + + `\r\n` + + `hello from smoke-smtp\r\n` + + // Same shape as the in-app curl SMTP sample (CodeSamples). + const result = spawnSync( + 'curl', + [ + '--silent', + '--show-error', + '--fail', + '--url', + smtpUrl, + '--mail-from', + from, + '--mail-rcpt', + to, + '--upload-file', + '-', + ], + { + input: mail, + encoding: 'utf8', + }, + ) + + if (result.error) { + throw result.error + } + if (result.status !== 0) { + const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim() + throw new Error(`curl SMTP failed (exit ${result.status})${detail ? `: ${detail}` : ''}`) + } +} + +async function waitForSubject(httpBase: string, subject: string): Promise { + console.log('[smoke-smtp] waiting for message in API') + const url = `${httpBase}/api/v1/messages` + for (let i = 0; i < 40; i++) { + const res = await fetch(url) + if (!res.ok) { + throw new Error(`GET ${url} failed: ${res.status}`) + } + const data = (await res.json()) as MessagesResponse + if (data.messages?.some((m) => m.subject === subject)) { + console.log(`[smoke-smtp] ok: found subject ${subject}`) + return + } + await sleep(250) + } + const dump = await fetch(url) + .then((r) => r.text()) + .catch(() => '') + throw new Error(`subject not found: ${subject}\n${dump}`) +} + +async function main(): Promise { + const httpBase = requireEnv('HTTP_BASE').replace(/\/$/, '') + const smtpUrl = requireEnv('SMTP_URL') + const subject = + process.env.SUBJECT ?? `ci-smtp-smoke-${Math.floor(Math.random() * 1e9)}-${Date.now()}` + const from = process.env.FROM ?? 'from@example.com' + const to = process.env.TO ?? 'to@example.com' + + await waitForApi(httpBase) + smtpSendViaCurl({ smtpUrl, from, to, subject }) + await waitForSubject(httpBase, subject) +} + +main().catch((err: unknown) => { + console.error('[smoke-smtp] error:', err instanceof Error ? err.message : err) + process.exit(1) +})