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);