diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts
index bbc44b95..b30e3d53 100644
--- a/e2e/fixtures/test-helpers.ts
+++ b/e2e/fixtures/test-helpers.ts
@@ -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 || `
${opts.text || 'Hello from E2E'}
`;
const textPart = opts.text || 'Hello from E2E';
+ const messageId = ``;
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'}`);
+ }
}
/**
diff --git a/worker/src/admin_api/e2e_test_api.ts b/worker/src/admin_api/e2e_test_api.ts
new file mode 100644
index 00000000..f5af30f7
--- /dev/null
+++ b/worker/src/admin_api/e2e_test_api.ts
@@ -0,0 +1,63 @@
+import { Context } from 'hono'
+import { getBooleanValue } from '../utils'
+
+// Direct DB insert — bypasses the email() handler.
+const seedMail = async (c: Context) => {
+ 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 || ``;
+ 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) => {
+ 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', ``);
+
+ 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 };
diff --git a/worker/src/admin_api/index.ts b/worker/src/admin_api/index.ts
index 8ee6e519..c3fd143f 100644
--- a/worker/src/admin_api/index.ts
+++ b/worker/src/admin_api/index.ts
@@ -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()
@@ -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 || ``;
- 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);