refactor: E2E tests use real email() handler (#870)

* refactor: add receive_mail E2E endpoint using real email() handler

Add /admin/test/receive_mail that constructs a mock ForwardableEmailMessage
and calls the real email() handler, so E2E tests exercise the full mail
processing pipeline. Extract both test endpoints into e2e_test_api.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: trigger CI

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dream Hunter
2026-03-06 10:15:02 +08:00
committed by GitHub
parent f98bbce234
commit e38015a5b6
3 changed files with 77 additions and 25 deletions

View File

@@ -29,7 +29,8 @@ export async function createTestAddress(
}
/**
* Seed a test email into the worker DB via the admin test endpoint.
* Seed a test email by exercising the real worker email() handler
* via the admin test endpoint.
*/
export async function seedTestMail(
ctx: APIRequestContext,
@@ -41,11 +42,13 @@ export async function seedTestMail(
const boundary = `----E2E${Date.now()}`;
const htmlPart = opts.html || `<p>${opts.text || 'Hello from E2E'}</p>`;
const textPart = opts.text || 'Hello from E2E';
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
const raw = [
`From: ${from}`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`MIME-Version: 1.0`,
`Content-Type: multipart/alternative; boundary="${boundary}"`,
``,
@@ -60,12 +63,16 @@ export async function seedTestMail(
`--${boundary}--`,
].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: { address, source: from, raw },
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) {
throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
}
const body = await res.json();
if (!body.success) {
throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`);
}
}
/**

View File

@@ -0,0 +1,63 @@
import { Context } from 'hono'
import { getBooleanValue } from '../utils'
// Direct DB insert — bypasses the email() handler.
const seedMail = async (c: Context<HonoCustomType>) => {
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 });
};
// Exercises the real email() handler with a mock ForwardableEmailMessage.
const receiveMail = async (c: Context<HonoCustomType>) => {
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
return c.text("Not available", 404);
}
const { from, to, raw } = await c.req.json();
if (!from || !to || !raw) {
return c.text("from, to and raw are required", 400);
}
// Parse MIME headers (unfold continuation lines, extract key:value pairs)
const headerSection = raw.substring(0, Math.max(0, raw.indexOf('\r\n\r\n')));
const headers = new Headers();
for (const line of headerSection.replace(/\r\n(?=[ \t])/g, ' ').split('\r\n')) {
const idx = line.indexOf(':');
if (idx > 0) headers.append(line.substring(0, idx).trim(), line.substring(idx + 1).trim());
}
if (!headers.has('Message-ID')) headers.set('Message-ID', `<e2e-${Date.now()}@test>`);
const rawBytes = new TextEncoder().encode(raw);
let rejected: string | undefined;
const mockMessage: ForwardableEmailMessage = {
from, to, headers,
rawSize: rawBytes.byteLength,
raw: new ReadableStream({ start(ctrl) { ctrl.enqueue(rawBytes); ctrl.close(); } }),
setReject(reason: string) { rejected = reason; },
forward: async () => ({ messageId: '' }),
reply: async () => ({ messageId: '' }),
};
const { email: emailHandler } = await import('../email');
await emailHandler(mockMessage, c.env, { waitUntil: () => {}, passThroughOnException: () => {} });
return c.json({ success: !rejected, ...(rejected ? { rejected } : {}) });
};
export default { seedMail, receiveMail };

View File

@@ -17,6 +17,7 @@ import db_api from './db_api'
import ip_blacklist_settings from './ip_blacklist_settings'
import ai_extract_settings from './ai_extract_settings'
import { EmailRuleSettings } from '../models'
import e2e_test_api from './e2e_test_api'
export const api = new Hono<HonoCustomType>()
@@ -389,25 +390,6 @@ api.post("/admin/ip_blacklist/settings", ip_blacklist_settings.saveIpBlacklistSe
api.get("/admin/ai_extract/settings", ai_extract_settings.getAiExtractSettings);
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 });
});
// E2E test endpoints
api.post('/admin/test/seed_mail', e2e_test_api.seedMail);
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail);