Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <image>` 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.
Expand Down Expand Up @@ -99,14 +98,16 @@ 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=$!
# give it a beat to bind sockets
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 '<div id="root"'
HTTP_BASE=http://127.0.0.1:13550 SMTP_URL=smtp://127.0.0.1:13535 \
node --experimental-strip-types scripts/smoke-smtp.ts
kill "$PID"

docker:
Expand All @@ -123,10 +124,14 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v6
with:
node-version: '22'

- name: docker build (Dockerfile)
run: docker build -t mailtrap-local:ci .

- name: boot Dockerfile image on default CMD + curl
- name: boot Dockerfile image on default CMD + curl + SMTP
run: |
docker run -d --name mtl -p 13550:3550 -p 13535:3535 mailtrap-local:ci
ok=
Expand All @@ -141,6 +146,8 @@ jobs:
exit 1
fi
curl -sf http://127.0.0.1:13550/api/v1/openapi.yaml | head -1 | grep -q '^openapi:'
HTTP_BASE=http://127.0.0.1:13550 SMTP_URL=smtp://127.0.0.1:13535 \
node --experimental-strip-types scripts/smoke-smtp.ts
docker rm -f mtl

# Reuse the binary from the source image so we do not rebuild the
Expand All @@ -152,7 +159,7 @@ jobs:
docker rm "$cid"
docker build -f Dockerfile.goreleaser -t mailtrap-local:ci-goreleaser .

- name: boot goreleaser image on default CMD + curl
- name: boot goreleaser image on default CMD + curl + SMTP
run: |
docker run -d --name mtl-gr -p 13551:3550 -p 13536:3535 mailtrap-local:ci-goreleaser
ok=
Expand All @@ -167,4 +174,6 @@ jobs:
exit 1
fi
curl -sf http://127.0.0.1:13551/api/v1/openapi.yaml | head -1 | grep -q '^openapi:'
HTTP_BASE=http://127.0.0.1:13551 SMTP_URL=smtp://127.0.0.1:13536 \
node --experimental-strip-types scripts/smoke-smtp.ts
docker rm -f mtl-gr
130 changes: 130 additions & 0 deletions scripts/smoke-smtp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Send one message over SMTP (via curl) and assert it appears in GET /api/v1/messages.
*
* Env (required):
* HTTP_BASE — e.g. http://127.0.0.1:13550
* SMTP_URL — e.g. smtp://127.0.0.1:13535
*
* Env (optional):
* SUBJECT — unique subject (default: ci-smtp-smoke-<random>-<epoch>)
* 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<void> {
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<void> {
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<void> {
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)
})
Loading