diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..4a28f43f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules/ +.git/ +vitepress-docs/ +smtp_proxy_server/ +mail-parser-wasm/ +db/ +**/.wrangler/ +**/dist/ +**/test-results/ +**/playwright-report/ +.DS_Store diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..411ef823 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,34 @@ +name: E2E Tests + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Run E2E tests + run: | + cd e2e + docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/test-results/ + e2e/playwright-report/ + retention-days: 30 + + - name: Cleanup + if: always() + run: | + cd e2e + docker compose down -v diff --git a/.gitignore b/.gitignore index 42fc586e..05ad2837 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,8 @@ dist wrangler.toml .dev.vars pnpm-lock.yaml + +# E2E test artifacts +e2e/test-results/ +e2e/playwright-report/ +e2e/.e2e-pids diff --git a/CHANGELOG.md b/CHANGELOG.md index 58b492b8..0f611a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ - fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本 - fix: |安全| 修复回复/转发邮件时的 XSS 风险,使用 DOMPurify 对 HTML 内容进行白名单消毒,对纯文本内容进行 HTML 转义 +### Testing + +- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行 +- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护 +- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用 + ### Improvements - style: |邮件列表| 优化收件箱和发件箱空状态显示,根据邮件数量显示不同提示信息,添加语义化图标 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 6f523f95..ab0647b2 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -23,6 +23,12 @@ - fix: |Frontend| Fix reply to HTML email losing original HTML content, prefer HTML message over plain text - fix: |Security| Fix XSS vulnerability in reply/forward mail content, sanitize HTML with DOMPurify whitelist and escape plain text +### Testing + +- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test` +- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization +- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled + ### Improvements - style: |Mail List| Improve empty state display for inbox and sent box, show different messages based on mail count, add semantic icons diff --git a/e2e/Dockerfile.e2e b/e2e/Dockerfile.e2e new file mode 100644 index 00000000..191141d3 --- /dev/null +++ b/e2e/Dockerfile.e2e @@ -0,0 +1,13 @@ +# Keep this version in sync with @playwright/test in package.json +FROM mcr.microsoft.com/playwright:v1.49.0-noble + +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +WORKDIR /app/e2e + +COPY e2e/package.json e2e/package-lock.json ./ +RUN npm ci + +COPY e2e/ . + +ENTRYPOINT ["/app/e2e/scripts/docker-entrypoint.sh"] diff --git a/e2e/Dockerfile.frontend b/e2e/Dockerfile.frontend new file mode 100644 index 00000000..e47d08da --- /dev/null +++ b/e2e/Dockerfile.frontend @@ -0,0 +1,23 @@ +FROM node:20-slim + +RUN corepack enable && corepack prepare pnpm@10.10.0 --activate + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install) + +COPY frontend/ . + +# Allow Docker internal hostnames (e.g. "frontend") to pass Vite's host check. +# Wrap the original config instead of sed-patching it — survives reformats. +RUN mv vite.config.js vite.config.original.js && \ + echo 'import config from "./vite.config.original.js";\ +config.server = { ...config.server, allowedHosts: true };\ +export default config;' > vite.config.js + +ENV VITE_API_BASE=http://worker:8787 + +EXPOSE 5173 + +CMD ["pnpm", "exec", "vite", "--port", "5173", "--host", "0.0.0.0"] diff --git a/e2e/Dockerfile.worker b/e2e/Dockerfile.worker new file mode 100644 index 00000000..52c54476 --- /dev/null +++ b/e2e/Dockerfile.worker @@ -0,0 +1,18 @@ +FROM node:20-slim + +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +RUN corepack enable && corepack prepare pnpm@10.10.0 --activate + +WORKDIR /app/worker + +COPY worker/package.json worker/pnpm-lock.yaml ./ +COPY worker/patches/ patches/ +RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install) + +COPY worker/src/ src/ +COPY worker/tsconfig.json ./ +COPY e2e/fixtures/wrangler.toml.e2e wrangler.toml + +EXPOSE 8787 + +CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"] diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..a849b0d1 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,57 @@ +# E2E Tests + +End-to-end tests for Cloudflare Temp Email using [Playwright](https://playwright.dev/) and [Mailpit](https://mailpit.axllent.org/), fully containerized with Docker Compose. + +## Prerequisites + +- **Docker** and **Docker Compose** + +## Quick Start + +```bash +cd e2e + +# Build, start all services, run tests, and exit +npm test + +# Clean up containers and volumes +npm run test:down +``` + +`npm test` runs `docker compose up --build`, which: +1. Starts **Mailpit** (SMTP on :1025, HTTP API on :8025) +2. Builds and starts the **Worker** (wrangler dev on :8787) +3. Builds and starts the **Frontend** (vite dev on :5173) +4. Builds and runs the **E2E runner** (Playwright), which waits for services, initializes the DB, and runs all tests + +The exit code reflects the test result. + +## Test Structure + +| Project | Directory | What it tests | +|---------|-----------|---------------| +| `api` | `tests/api/` | Worker API endpoints — health check, address CRUD, send mail via SMTP | +| `browser` | `tests/browser/` | Frontend UI — login, inbox view, reply with HTML, XSS sanitization | + +## Services + +| Service | Container | Port | Purpose | +|---------|-----------|------|---------| +| Mailpit SMTP | `mailpit` | 1025 | Captures outgoing emails | +| Mailpit HTTP | `mailpit` | 8025 | API to verify captured emails | +| Worker | `worker` | 8787 | Backend API with E2E config | +| Frontend | `frontend` | 5173 | Vue frontend dev server | + +## Test Results + +Test results and HTML reports are exported via volumes: +- `e2e/test-results/` — test artifacts +- `e2e/playwright-report/` — HTML report + +## Configuration + +The E2E worker uses `fixtures/wrangler.toml.e2e` with: +- `E2E_TEST_MODE = true` — enables test seed endpoint +- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls +- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval +- SMTP pointed at Mailpit container (`mailpit:1025`) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 00000000..6766f039 --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,49 @@ +services: + mailpit: + image: axllent/mailpit:v1.29 + ports: + - "1025:1025" + - "8025:8025" + + worker: + build: + context: .. + dockerfile: e2e/Dockerfile.worker + ports: + - "8787:8787" + depends_on: + - mailpit + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8787/health_check"] + interval: 3s + timeout: 5s + start_period: 10s + retries: 20 + + frontend: + build: + context: .. + dockerfile: e2e/Dockerfile.frontend + ports: + - "5173:5173" + depends_on: + worker: + condition: service_healthy + + e2e-runner: + build: + context: .. + dockerfile: e2e/Dockerfile.e2e + environment: + WORKER_URL: http://worker:8787 + FRONTEND_URL: http://frontend:5173 + MAILPIT_API: http://mailpit:8025/api + CI: "true" + depends_on: + worker: + condition: service_healthy + frontend: + condition: service_started + volumes: + - ./test-results:/app/e2e/test-results + - ./playwright-report:/app/e2e/playwright-report diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts new file mode 100644 index 00000000..849beb9c --- /dev/null +++ b/e2e/fixtures/test-helpers.ts @@ -0,0 +1,179 @@ +import { APIRequestContext } from '@playwright/test'; +import WebSocket from 'ws'; + +export const WORKER_URL = process.env.WORKER_URL!; +export const FRONTEND_URL = process.env.FRONTEND_URL!; +export const MAILPIT_API = process.env.MAILPIT_API!; +export const TEST_DOMAIN = 'test.example.com'; + +/** + * Create a new email address via the worker API. + * Appends a timestamp suffix to avoid UNIQUE constraint collisions + * with persistent D1 data from previous test runs. + * Returns the JWT and full address string. + */ +export async function createTestAddress( + ctx: APIRequestContext, + name: string, + domain: string = TEST_DOMAIN +): Promise<{ jwt: string; address: string }> { + const uniqueName = `${name}${Date.now()}`; + const res = await ctx.post(`${WORKER_URL}/api/new_address`, { + data: { name: uniqueName, domain }, + }); + if (!res.ok()) { + throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + return { jwt: body.jwt, address: body.address }; +} + +/** + * Seed a test email into the worker DB via the admin test endpoint. + */ +export async function seedTestMail( + ctx: APIRequestContext, + address: string, + opts: { subject?: string; html?: string; text?: string; from?: string } +): Promise { + const from = opts.from || `sender@${TEST_DOMAIN}`; + const subject = opts.subject || 'Test Email'; + const boundary = `----E2E${Date.now()}`; + const htmlPart = opts.html || `

${opts.text || 'Hello from E2E'}

`; + const textPart = opts.text || 'Hello from E2E'; + + const raw = [ + `From: ${from}`, + `To: ${address}`, + `Subject: ${subject}`, + `MIME-Version: 1.0`, + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ``, + `--${boundary}`, + `Content-Type: text/plain; charset=utf-8`, + ``, + textPart, + `--${boundary}`, + `Content-Type: text/html; charset=utf-8`, + ``, + htmlPart, + `--${boundary}--`, + ].join('\r\n'); + + const res = await ctx.post(`${WORKER_URL}/admin/test/seed_mail`, { + data: { address, source: from, raw }, + }); + if (!res.ok()) { + throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`); + } +} + +/** + * Delete all messages in Mailpit. + */ +export async function deleteAllMailpitMessages(ctx: APIRequestContext) { + const res = await ctx.delete(`${MAILPIT_API}/v1/messages`); + if (!res.ok()) { + throw new Error(`Failed to delete Mailpit messages: ${res.status()} ${await res.text()}`); + } +} + +/** + * Derive the Mailpit WebSocket URL from the REST API URL. + * MAILPIT_API is like "http://mailpit:8025/api" → ws://mailpit:8025/api/events + */ +function mailpitWsUrl(): string { + return MAILPIT_API.replace(/^http/, 'ws') + '/events'; +} + +/** + * Wait for a message matching `predicate` to arrive in Mailpit. + * + * Connects to Mailpit's WebSocket `/api/events` and listens for + * `Type: "new"` events. When a matching message arrives, resolves + * immediately — no polling, no arbitrary sleeps. + * + * Returns `{ ready, message }`: + * - `ready` resolves when the WebSocket connection is open + * - `message` resolves with the matched message summary + * + * Usage: await ready before triggering the send to avoid race conditions. + */ +export function onMailpitMessage( + predicate: (msg: any) => boolean, + { timeout = 10_000 }: { timeout?: number } = {} +): { ready: Promise; message: Promise } { + let readyResolve: () => void; + let readyReject: (err: Error) => void; + const ready = new Promise((resolve, reject) => { + readyResolve = resolve; + readyReject = reject; + }); + + const message = new Promise((resolve, reject) => { + let settled = false; + const ws = new WebSocket(mailpitWsUrl()); + const timer = setTimeout(() => { + ws.close(); + if (!settled) { settled = true; reject(new Error('Mailpit message not received within timeout')); } + }, timeout); + + ws.on('open', () => readyResolve()); + + ws.on('message', (data: WebSocket.Data) => { + try { + const event = JSON.parse(data.toString()); + if (event.Type === 'new' && predicate(event.Data)) { + clearTimeout(timer); + ws.close(); + if (!settled) { settled = true; resolve(event.Data); } + } + } catch { /* ignore parse errors */ } + }); + + ws.on('close', () => { + clearTimeout(timer); + if (!settled) { settled = true; reject(new Error('Mailpit WebSocket closed before matching message')); } + }); + + ws.on('error', (err: Error) => { + clearTimeout(timer); + readyReject(err); + if (!settled) { settled = true; reject(err); } + }); + }); + + return { ready, message }; +} + +/** + * Request send mail access for an address. + * Must be called before sending mail — creates the address_sender row + * with the DEFAULT_SEND_BALANCE configured in the worker. + */ +export async function requestSendAccess( + ctx: APIRequestContext, + jwt: string +): Promise { + const res = await ctx.post(`${WORKER_URL}/api/requset_send_mail_access`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + if (!res.ok()) { + throw new Error(`Failed to request send access: ${res.status()} ${await res.text()}`); + } +} + +/** + * Delete a test address via its JWT. + */ +export async function deleteAddress( + ctx: APIRequestContext, + jwt: string +): Promise { + const res = await ctx.delete(`${WORKER_URL}/api/delete_address`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + if (!res.ok()) { + throw new Error(`Failed to delete address: ${res.status()} ${await res.text()}`); + } +} diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e new file mode 100644 index 00000000..e6859955 --- /dev/null +++ b/e2e/fixtures/wrangler.toml.e2e @@ -0,0 +1,26 @@ +name = "cloudflare_temp_email" +main = "src/worker.ts" +compatibility_date = "2025-04-01" +compatibility_flags = [ "nodejs_compat" ] +keep_vars = true + +[vars] +PREFIX = "tmp" +DEFAULT_DOMAINS = ["test.example.com"] +DOMAINS = ["test.example.com"] +JWT_SECRET = "e2e-test-secret-key" +BLACK_LIST = "" +ENABLE_USER_CREATE_EMAIL = true +ENABLE_USER_DELETE_EMAIL = true +ENABLE_AUTO_REPLY = false +DEFAULT_SEND_BALANCE = 10 +DISABLE_ADMIN_PASSWORD_CHECK = true +E2E_TEST_MODE = true +SMTP_CONFIG = """ +{"test.example.com":{"host":"mailpit","port":1025,"secure":false}} +""" + +[[d1_databases]] +binding = "DB" +database_name = "e2e-temp-email" +database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000" diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000..84c59932 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,127 @@ +{ + "name": "cloudflare-temp-email-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloudflare-temp-email-e2e", + "devDependencies": { + "@playwright/test": "1.49.0", + "@types/ws": "^8.5.0", + "ws": "^8.18.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0.tgz", + "integrity": "sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.49.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz", + "integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz", + "integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..03e7079e --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "cloudflare-temp-email-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner", + "test:down": "docker compose down -v" + }, + "devDependencies": { + "@playwright/test": "1.49.0", + "@types/ws": "^8.5.0", + "ws": "^8.18.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..245a062f --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, devices } from '@playwright/test'; + +const WORKER_BASE = process.env.WORKER_URL!; +const FRONTEND_BASE = process.env.FRONTEND_URL!; + +export default defineConfig({ + timeout: 30_000, + retries: 0, + workers: 1, + reporter: [['html', { open: 'never' }]], + projects: [ + { + name: 'api', + testDir: './tests/api', + use: { + baseURL: WORKER_BASE, + }, + }, + { + name: 'browser', + testDir: './tests/browser', + use: { + baseURL: FRONTEND_BASE, + ...devices['Desktop Chrome'], + }, + }, + ], +}); diff --git a/e2e/scripts/docker-entrypoint.sh b/e2e/scripts/docker-entrypoint.sh new file mode 100755 index 00000000..9b9bb2c1 --- /dev/null +++ b/e2e/scripts/docker-entrypoint.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Waiting for worker at $WORKER_URL ..." +for i in $(seq 1 60); do + if curl -sf "$WORKER_URL/health_check" > /dev/null 2>&1; then + echo " Worker ready after ${i}s" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: Worker not ready after 60s" + exit 1 + fi + sleep 1 +done + +echo "==> Waiting for frontend at $FRONTEND_URL ..." +for i in $(seq 1 60); do + if curl -sf "$FRONTEND_URL" > /dev/null 2>&1; then + echo " Frontend ready after ${i}s" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: Frontend not ready after 60s" + exit 1 + fi + sleep 1 +done + +echo "==> Initializing database" +curl -sf -X POST "$WORKER_URL/admin/db_initialize" > /dev/null +curl -sf -X POST "$WORKER_URL/admin/db_migration" > /dev/null +echo " Database initialized" + +echo "==> Running Playwright tests" +exec npx playwright test "$@" diff --git a/e2e/tests/api/address-lifecycle.spec.ts b/e2e/tests/api/address-lifecycle.spec.ts new file mode 100644 index 00000000..cc0f5c7f --- /dev/null +++ b/e2e/tests/api/address-lifecycle.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; +import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, requestSendAccess } from '../../fixtures/test-helpers'; + +test.describe('Address Lifecycle', () => { + test('create address, request send access, fetch settings, then delete', async ({ request }) => { + // Create address + const { jwt, address } = await createTestAddress(request, 'lifecycle-test'); + expect(address).toContain('@' + TEST_DOMAIN); + expect(jwt).toBeTruthy(); + + // Request send access (creates address_sender row with DEFAULT_SEND_BALANCE) + await requestSendAccess(request, jwt); + + // Fetch address settings — balance should match DEFAULT_SEND_BALANCE=10 + const settingsRes = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(settingsRes.ok()).toBe(true); + const settings = await settingsRes.json(); + expect(settings.send_balance).toBe(10); + + // Delete address + await deleteAddress(request, jwt); + + // Verify address is gone — settings should fail + const afterDelete = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(afterDelete.ok()).toBe(false); + }); +}); diff --git a/e2e/tests/api/health.spec.ts b/e2e/tests/api/health.spec.ts new file mode 100644 index 00000000..2701f748 --- /dev/null +++ b/e2e/tests/api/health.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; +import { WORKER_URL } from '../../fixtures/test-helpers'; + +test.describe('Health & Settings', () => { + test('GET /health_check returns OK', async ({ request }) => { + const res = await request.get(`${WORKER_URL}/health_check`); + expect(res.ok()).toBe(true); + expect(await res.text()).toBe('OK'); + }); + + test('GET /open_api/settings returns correct domains and sendMail enabled', async ({ request }) => { + const res = await request.get(`${WORKER_URL}/open_api/settings`); + expect(res.ok()).toBe(true); + + const settings = await res.json(); + expect(settings.domains).toContain('test.example.com'); + expect(settings.defaultDomains).toContain('test.example.com'); + expect(settings.enableSendMail).toBe(true); + expect(settings.enableUserCreateEmail).toBe(true); + expect(settings.enableUserDeleteEmail).toBe(true); + }); +}); diff --git a/e2e/tests/api/send-mail.spec.ts b/e2e/tests/api/send-mail.spec.ts new file mode 100644 index 00000000..7b9c0985 --- /dev/null +++ b/e2e/tests/api/send-mail.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { + createTestAddress, + deleteAddress, + deleteAllMailpitMessages, + requestSendAccess, + onMailpitMessage, + WORKER_URL, +} from '../../fixtures/test-helpers'; + +test.describe('Send Mail via SMTP', () => { + test.beforeEach(async ({ request }) => { + await deleteAllMailpitMessages(request); + }); + + test('send HTML email and verify in Mailpit', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'sender-test'); + + // Must request send access before sending (creates address_sender row) + await requestSendAccess(request, jwt); + + const subject = `E2E Test ${Date.now()}`; + const htmlContent = '

Hello

This is an E2E test email.

'; + + // Start listening for the message BEFORE sending + const listener = onMailpitMessage((m) => m.Subject === subject); + await listener.ready; + + // Send mail via worker API + const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, { + headers: { Authorization: `Bearer ${jwt}` }, + data: { + from_name: 'E2E Sender', + to_name: 'E2E Recipient', + to_mail: 'recipient@test.example.com', + subject, + content: htmlContent, + is_html: true, + }, + }); + expect(sendRes.ok()).toBe(true); + + // Wait for Mailpit WebSocket "new" event — no polling + const mail = await listener.message; + expect(mail.From.Address).toBe(address); + expect(mail.To[0].Address).toBe('recipient@test.example.com'); + + // Cleanup + await deleteAddress(request, jwt); + }); +}); diff --git a/e2e/tests/browser/inbox.spec.ts b/e2e/tests/browser/inbox.spec.ts new file mode 100644 index 00000000..2470ee6f --- /dev/null +++ b/e2e/tests/browser/inbox.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; +import { + FRONTEND_URL, + createTestAddress, + seedTestMail, + deleteAddress, +} from '../../fixtures/test-helpers'; +import { request as apiRequest } from '@playwright/test'; + +test.describe('Inbox Browser Flow', () => { + test('login via JWT, view inbox, open email', async ({ page }) => { + // Create API context for setup + const api = await apiRequest.newContext(); + + const { jwt, address } = await createTestAddress(api, 'inbox-browser'); + + // Seed an email + const subject = `Browser Test ${Date.now()}`; + await seedTestMail(api, address, { + subject, + html: '

Welcome

This is a browser test email.

', + }); + + // Login via JWT query param with /en/ path to force English locale + await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`); + + // The mail subject should be visible in the inbox list item + const mailItem = page.getByRole('listitem').getByText(subject); + await expect(mailItem).toBeVisible({ timeout: 10_000 }); + + // Click to open the email + await mailItem.click(); + + // Verify the email detail panel shows the subject as a heading + // (n-card-header wraps n-card-header__main, both match heading role — use .first()) + await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 5_000 }); + + // Cleanup + await deleteAddress(api, jwt); + await api.dispose(); + }); +}); diff --git a/e2e/tests/browser/reply-html.spec.ts b/e2e/tests/browser/reply-html.spec.ts new file mode 100644 index 00000000..33865ddf --- /dev/null +++ b/e2e/tests/browser/reply-html.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from '@playwright/test'; +import { + FRONTEND_URL, + createTestAddress, + seedTestMail, + deleteAddress, + deleteAllMailpitMessages, + requestSendAccess, +} from '../../fixtures/test-helpers'; +import { request as apiRequest } from '@playwright/test'; + +test.describe('Reply HTML & XSS Sanitization', () => { + test('reply to HTML email — XSS payloads stripped, HTML preserved', async ({ page }) => { + const api = await apiRequest.newContext(); + await deleteAllMailpitMessages(api); + + const { jwt, address } = await createTestAddress(api, 'reply-xss'); + + // Request send access so Reply can navigate to compose form + await requestSendAccess(api, jwt); + + // Seed email with XSS payloads embedded in HTML + const xssHtml = [ + '
', + '

Important Message

', + '

Please review this content.

', + ' ', + ' ', + ' click me', + '

Styled paragraph

', + '
', + ].join('\n'); + + await seedTestMail(api, address, { + subject: 'XSS Test Email', + html: xssHtml, + from: 'attacker@test.example.com', + }); + + // Single dialog handler with phase tracking. + // During email rendering, the mail viewer uses an unsandboxed iframe so + // inline event handlers like onerror may fire — we dismiss those. + // After clicking Reply, any dialog means the compose path failed to sanitize. + let inComposePhase = false; + let composeDialogAppeared = false; + page.on('dialog', async (dialog) => { + if (inComposePhase) composeDialogAppeared = true; + await dialog.dismiss(); + }); + + // Login with English locale + await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`); + + // Open the email (use listitem to avoid strict mode violation + // when detail panel also shows the subject) + const mailItem = page.getByRole('listitem').getByText('XSS Test Email'); + await expect(mailItem).toBeVisible({ timeout: 10_000 }); + await mailItem.click(); + + // Wait for Reply button to appear — signals email content has rendered + const replyButton = page.locator('button').filter({ hasText: /Reply/i }).first(); + await expect(replyButton).toBeVisible({ timeout: 10_000 }); + + // Click Reply — from here on, dialogs indicate sanitization failure (#857) + inComposePhase = true; + await replyButton.click(); + + // In the reply compose area, check that the forwarded HTML is sanitized: + // -