diff --git a/src/routes/inbound.test.ts b/src/routes/inbound.test.ts new file mode 100644 index 0000000..4b97760 --- /dev/null +++ b/src/routes/inbound.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import worker from "../index"; +import { server, createMockEnv, MockR2 } from "../test/setup"; +import type { Env } from "../types"; +import type { ForwardEmailPayload } from "../lib/forwardemail"; + +const AUTHORIZED_IP = "138.197.213.185"; // first fallback IP +const DOMAIN = "test.getmynews.app"; +const VALID_FEED_ID = "apple.mountain.42"; +const VALID_TO = `${VALID_FEED_ID}@${DOMAIN}`; + +function makeRequest( + payload: Partial | null, + ip = AUTHORIZED_IP, +): Request { + return new Request(`https://${DOMAIN}/api/inbound`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "CF-Connecting-IP": ip, + }, + body: payload === null ? "not-json{{{" : JSON.stringify(payload), + }); +} + +function makePayload( + overrides: Partial = {}, +): ForwardEmailPayload { + return { + recipients: [VALID_TO], + from: { + value: [{ address: "sender@example.com", name: "Sender" }], + text: "Sender ", + }, + subject: "Test Subject", + html: "

Hello

", + ...overrides, + }; +} + +// Stub the ForwardEmail IP lookup so tests work without network access. +// Returns a minimal list that includes AUTHORIZED_IP. +function stubForwardEmailIps() { + server.use( + http.get("https://forwardemail.net/ips/v4.json", () => + HttpResponse.json([ + { + hostname: "mx1.forwardemail.net", + ipv4: [AUTHORIZED_IP], + updated: "", + }, + ]), + ), + ); +} + +describe("POST /api/inbound — IP middleware", () => { + let env: Env; + + beforeEach(async () => { + env = createMockEnv() as unknown as Env; + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ allowed_senders: [] }), + ); + }); + + it("returns 401 when IP is not in the ForwardEmail allowlist", async () => { + stubForwardEmailIps(); + const res = await worker.fetch(makeRequest(makePayload(), "1.2.3.4"), env); + expect(res.status).toBe(401); + }); + + it("passes through to the handler when IP is authorised", async () => { + stubForwardEmailIps(); + const res = await worker.fetch( + makeRequest(makePayload(), AUTHORIZED_IP), + env, + ); + expect(res.status).toBe(200); + }); + + it("falls back to hardcoded IPs and accepts request when ForwardEmail API is down", async () => { + server.use( + http.get("https://forwardemail.net/ips/v4.json", () => + HttpResponse.error(), + ), + ); + // AUTHORIZED_IP is in the hardcoded fallback list + const res = await worker.fetch( + makeRequest(makePayload(), AUTHORIZED_IP), + env, + ); + expect(res.status).toBe(200); + }); +}); + +describe("POST /api/inbound — handler logic", () => { + let env: Env; + + beforeEach(() => { + stubForwardEmailIps(); + env = createMockEnv() as unknown as Env; + }); + + it("returns 500 on malformed JSON body", async () => { + const res = await worker.fetch(makeRequest(null), env); + expect(res.status).toBe(500); + }); + + it("returns 400 when recipient address has no valid feed ID", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({}), + ); + const res = await worker.fetch( + makeRequest(makePayload({ recipients: ["notafeedid@example.com"] })), + env, + ); + expect(res.status).toBe(400); + }); + + it("returns 404 when feed does not exist in KV", async () => { + const res = await worker.fetch(makeRequest(makePayload()), env); + expect(res.status).toBe(404); + }); + + it("returns 403 when sender is not in the allowlist", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ allowed_senders: ["allowed@example.com"] }), + ); + const res = await worker.fetch(makeRequest(makePayload()), env); + expect(res.status).toBe(403); + }); + + it("returns 200 when sender matches allowlist by exact address", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ allowed_senders: ["sender@example.com"] }), + ); + const res = await worker.fetch(makeRequest(makePayload()), env); + expect(res.status).toBe(200); + }); + + it("returns 200 when sender matches allowlist by domain", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ allowed_senders: ["example.com"] }), + ); + const res = await worker.fetch(makeRequest(makePayload()), env); + expect(res.status).toBe(200); + }); + + it("returns 200 when allowlist is empty (open feed)", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ allowed_senders: [] }), + ); + const res = await worker.fetch(makeRequest(makePayload()), env); + expect(res.status).toBe(200); + }); + + it("stores email data and metadata in KV on success", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({}), + ); + const res = await worker.fetch( + makeRequest(makePayload({ subject: "Hello KV" })), + env, + ); + expect(res.status).toBe(200); + + const metadata = (await env.EMAIL_STORAGE.get( + `feed:${VALID_FEED_ID}:metadata`, + { type: "json" }, + )) as any; + expect(metadata.emails).toHaveLength(1); + expect(metadata.emails[0].subject).toBe("Hello KV"); + + const emailData = (await env.EMAIL_STORAGE.get(metadata.emails[0].key, { + type: "json", + })) as any; + expect(emailData.subject).toBe("Hello KV"); + expect(emailData.from).toContain("sender@example.com"); + }); + + it("extracts sender from from.text when from.value is absent", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ allowed_senders: ["sender@example.com"] }), + ); + const payload = makePayload({ + from: { text: "sender@example.com" }, + }); + const res = await worker.fetch(makeRequest(payload), env); + expect(res.status).toBe(200); + }); + + it("uses HTML body when available, falls back to text", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({}), + ); + const htmlRes = await worker.fetch( + makeRequest(makePayload({ html: "rich", text: "plain" })), + env, + ); + expect(htmlRes.status).toBe(200); + const htmlMeta = (await env.EMAIL_STORAGE.get( + `feed:${VALID_FEED_ID}:metadata`, + { type: "json" }, + )) as any; + const htmlEmail = (await env.EMAIL_STORAGE.get(htmlMeta.emails[0].key, { + type: "json", + })) as any; + expect(htmlEmail.content).toBe("rich"); + }); +}); + +describe("POST /api/inbound — attachment upload", () => { + let env: Env; + + beforeEach(() => { + stubForwardEmailIps(); + env = createMockEnv({ withR2: true }) as unknown as Env; + }); + + it("uploads attachments to R2 and records ids in metadata", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({}), + ); + const payload = makePayload({ + attachments: [ + { + filename: "doc.pdf", + contentType: "application/pdf", + content: { type: "Buffer", data: [80, 68, 70] }, + }, + ], + }); + const res = await worker.fetch(makeRequest(payload), env); + expect(res.status).toBe(200); + + const metadata = (await env.EMAIL_STORAGE.get( + `feed:${VALID_FEED_ID}:metadata`, + { type: "json" }, + )) as any; + expect(metadata.emails[0].attachmentIds).toHaveLength(1); + + const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2; + const attachmentId = metadata.emails[0].attachmentIds[0]; + expect(mockR2._has(attachmentId)).toBe(true); + }); + + it("skips R2 when attachment content is null", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({}), + ); + const payload = makePayload({ + attachments: [ + { + filename: "empty.txt", + contentType: "text/plain", + content: undefined, + }, + ], + }); + const res = await worker.fetch(makeRequest(payload), env); + expect(res.status).toBe(200); + + const metadata = (await env.EMAIL_STORAGE.get( + `feed:${VALID_FEED_ID}:metadata`, + { type: "json" }, + )) as any; + expect(metadata.emails[0].attachmentIds).toBeUndefined(); + }); +}); diff --git a/src/routes/inbound.ts b/src/routes/inbound.ts index 8aebfe8..4518f24 100644 --- a/src/routes/inbound.ts +++ b/src/routes/inbound.ts @@ -14,7 +14,13 @@ export async function handle(c: Context): Promise { contentType: payload.html ? "HTML" : "Text", }); - return handleForwardEmail(payload, env, c.executionCtx); + let ctx: ExecutionContext | undefined; + try { + ctx = c.executionCtx; + } catch { + // No ExecutionContext in this environment (e.g. tests); WebSub notifications will be skipped + } + return handleForwardEmail(payload, env, ctx); } catch (error) { console.error("Error processing email:", error); return new Response("Error processing email", { status: 500 });