mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-07 00:17:12 +08:00
test: add Dockerized E2E test environment with Playwright + Mailpit (#860)
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
**/node_modules/
|
||||||
|
.git/
|
||||||
|
vitepress-docs/
|
||||||
|
smtp_proxy_server/
|
||||||
|
mail-parser-wasm/
|
||||||
|
db/
|
||||||
|
**/.wrangler/
|
||||||
|
**/dist/
|
||||||
|
**/test-results/
|
||||||
|
**/playwright-report/
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
||||||
@@ -137,3 +137,8 @@ dist
|
|||||||
wrangler.toml
|
wrangler.toml
|
||||||
.dev.vars
|
.dev.vars
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# E2E test artifacts
|
||||||
|
e2e/test-results/
|
||||||
|
e2e/playwright-report/
|
||||||
|
e2e/.e2e-pids
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
|
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
|
||||||
- fix: |安全| 修复回复/转发邮件时的 XSS 风险,使用 DOMPurify 对 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
|
### Improvements
|
||||||
|
|
||||||
- style: |邮件列表| 优化收件箱和发件箱空状态显示,根据邮件数量显示不同提示信息,添加语义化图标
|
- style: |邮件列表| 优化收件箱和发件箱空状态显示,根据邮件数量显示不同提示信息,添加语义化图标
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
- fix: |Frontend| Fix reply to HTML email losing original HTML content, prefer HTML message over plain text
|
- 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
|
- 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
|
### Improvements
|
||||||
|
|
||||||
- style: |Mail List| Improve empty state display for inbox and sent box, show different messages based on mail count, add semantic icons
|
- style: |Mail List| Improve empty state display for inbox and sent box, show different messages based on mail count, add semantic icons
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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`)
|
||||||
@@ -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
|
||||||
@@ -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<void> {
|
||||||
|
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||||
|
const subject = opts.subject || 'Test Email';
|
||||||
|
const boundary = `----E2E${Date.now()}`;
|
||||||
|
const htmlPart = opts.html || `<p>${opts.text || 'Hello from E2E'}</p>`;
|
||||||
|
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<void>; message: Promise<any> } {
|
||||||
|
let readyResolve: () => void;
|
||||||
|
let readyReject: (err: Error) => void;
|
||||||
|
const ready = new Promise<void>((resolve, reject) => {
|
||||||
|
readyResolve = resolve;
|
||||||
|
readyReject = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = new Promise<any>((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<void> {
|
||||||
|
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<void> {
|
||||||
|
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()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
Generated
+127
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Executable
+36
@@ -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 "$@"
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = '<h1>Hello</h1><p>This is an <b>E2E test</b> email.</p>';
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: '<h1>Welcome</h1><p>This is a <b>browser test</b> email.</p>',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = [
|
||||||
|
'<div>',
|
||||||
|
' <h1>Important Message</h1>',
|
||||||
|
' <p>Please review this content.</p>',
|
||||||
|
' <script>alert("xss")</script>',
|
||||||
|
' <img src=x onerror="alert(1)">',
|
||||||
|
' <a href="javascript:alert(2)">click me</a>',
|
||||||
|
' <p style="color:red">Styled paragraph</p>',
|
||||||
|
'</div>',
|
||||||
|
].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:
|
||||||
|
// - <script> tags should be removed
|
||||||
|
// - onerror attributes should be removed
|
||||||
|
// - javascript: URLs should be removed
|
||||||
|
const composeArea = page.locator('.ql-editor, [contenteditable], textarea').first();
|
||||||
|
await expect(composeArea).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Use inputValue() for <textarea> (Vue v-model sets .value, not innerHTML),
|
||||||
|
// fall back to innerHTML() for contenteditable elements
|
||||||
|
const tagName = await composeArea.evaluate(el => el.tagName.toLowerCase());
|
||||||
|
const content = tagName === 'textarea'
|
||||||
|
? await composeArea.inputValue()
|
||||||
|
: await composeArea.innerHTML();
|
||||||
|
|
||||||
|
// Verify content is non-empty (guard against vacuous pass)
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// XSS vectors must be stripped
|
||||||
|
expect(content).not.toContain('<script>');
|
||||||
|
expect(content).not.toContain('onerror');
|
||||||
|
expect(content).not.toContain('javascript:');
|
||||||
|
|
||||||
|
// No XSS dialog should have fired in the compose area
|
||||||
|
expect(composeDialogAppeared).toBe(false);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await deleteAddress(api, jwt);
|
||||||
|
await api.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -388,3 +388,26 @@ api.post("/admin/ip_blacklist/settings", ip_blacklist_settings.saveIpBlacklistSe
|
|||||||
// AI extract settings
|
// AI extract settings
|
||||||
api.get("/admin/ai_extract/settings", ai_extract_settings.getAiExtractSettings);
|
api.get("/admin/ai_extract/settings", ai_extract_settings.getAiExtractSettings);
|
||||||
api.post("/admin/ai_extract/settings", ai_extract_settings.saveAiExtractSettings);
|
api.post("/admin/ai_extract/settings", ai_extract_settings.saveAiExtractSettings);
|
||||||
|
|
||||||
|
// Test-only endpoint for seeding emails. MUST NOT be enabled in production.
|
||||||
|
api.post('/admin/test/seed_mail', async (c) => {
|
||||||
|
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
|
||||||
|
return c.text("Not available", 404);
|
||||||
|
}
|
||||||
|
const { address, source, raw, message_id } = await c.req.json();
|
||||||
|
if (!address || !raw) {
|
||||||
|
return c.text("address and raw are required", 400);
|
||||||
|
}
|
||||||
|
if (raw.length > 1_000_000) {
|
||||||
|
return c.text("raw content too large", 400);
|
||||||
|
}
|
||||||
|
if (message_id && message_id.length > 255) {
|
||||||
|
return c.text("message_id too long", 400);
|
||||||
|
}
|
||||||
|
const msgId = message_id || `<e2e-${Date.now()}@test>`;
|
||||||
|
const { success } = await c.env.DB.prepare(
|
||||||
|
`INSERT INTO raw_mails (message_id, source, address, raw, created_at)`
|
||||||
|
+ ` VALUES (?, ?, ?, ?, datetime('now'))`
|
||||||
|
).bind(msgId, source || address, address, raw).run();
|
||||||
|
return c.json({ success });
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+3
@@ -93,6 +93,9 @@ type Bindings = {
|
|||||||
// AI extraction config
|
// AI extraction config
|
||||||
ENABLE_AI_EMAIL_EXTRACT: string | boolean | undefined
|
ENABLE_AI_EMAIL_EXTRACT: string | boolean | undefined
|
||||||
AI_EXTRACT_MODEL: string | undefined
|
AI_EXTRACT_MODEL: string | undefined
|
||||||
|
|
||||||
|
// E2E testing
|
||||||
|
E2E_TEST_MODE: string | boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
type JwtPayload = {
|
type JwtPayload = {
|
||||||
|
|||||||
Reference in New Issue
Block a user