import { beforeEach, describe, expect, mock, test } from "bun:test"; const env = { EMAIL_REPLY_DOMAIN: "gitfor.dev", SECRETS_KEY: "k".repeat(32), PUBLIC_URL: "https://gitfor.dev" }; mock.module("cloudflare:workers", () => ({ env, waitUntil: () => undefined })); type Issue = { id: string; repo_id: string; is_pull: number; locked: number; number: number; title: string }; const users = new Map([["u1aaaaaaaaaaaaaaaaaa", { account_id: "u1aaaaaaaaaaaaaaaaaa", name: "alice" }]]); const emails = new Map([["u1aaaaaaaaaaaaaaaaaa", [{ email: "Alice@Example.com", verified: true, primary: true }]]]); let issues = new Map(); let access: "read" | "none" = "read"; let comments: Array<{ issueId: string; userId: string; body: string }> = []; mock.module("./auth", () => ({ loadUserById: async (id: string) => users.get(id) ?? null, toViewer: (user: { account_id: string; name: string }) => ({ id: user.account_id, name: user.name, displayName: null, email: "", isAdmin: false, }), })); mock.module("./db", () => ({ first: async (_sql: string, id: string) => issues.get(id) ?? null })); mock.module("./user-emails", () => ({ listAccountEmails: async (id: string) => emails.get(id) ?? [] })); mock.module("./limits", () => ({ limitWrite: async () => undefined })); mock.module("./repos", () => ({ loadRepoById: async (id: string) => ({ id, owner_name: "acme", name: "web", has_issues: 1 }), requireRepoMutation: async () => { if (access === "none") { const { notFoundError } = await import("./errors"); throw notFoundError("Repository not found"); } return { repo: { id: "r1" }, permission: "read" }; }, })); mock.module("./issue-comments", () => ({ createIssueComment: async (_access: unknown, issue: Issue, viewer: { id: string }, body: string) => { comments.push({ issueId: issue.id, userId: viewer.id, body }); }, })); const { handleReplyEmail, isReplyAddress, replyAddressFor } = await import("./email-reply"); const USER = "u1aaaaaaaaaaaaaaaaaa"; const ISSUE = "i1bbbbbbbbbbbbbbbbbb"; function inbound(to: string, raw: string, extraHeaders: Record = {}) { const bytes = new TextEncoder().encode(raw); const result: { rejected: string | null } = { rejected: null }; const message = { to, from: "bounce@example.com", headers: new Headers(extraHeaders), raw: new Response(bytes).body as ReadableStream, rawSize: bytes.byteLength, setReject: (reason: string) => { result.rejected = reason; }, } as unknown as ForwardableEmailMessage; return { message, result }; } function mime(from: string, body: string) { return [ `From: Alice <${from}>`, "To: reply@gitfor.dev", "Subject: Re: [gitfor] acme/web#12: Crash", "Content-Type: text/plain; charset=utf-8", "", body, ].join("\r\n"); } beforeEach(() => { issues = new Map([[ISSUE, { id: ISSUE, repo_id: "r1", is_pull: 0, locked: 0, number: 12, title: "Crash" }]]); access = "read"; comments = []; }); describe("reply addresses", () => { test("are per user and thread, fit in a local part, and are recognised", async () => { const address = await replyAddressFor(USER, ISSUE); expect(address).toMatch(/^reply\+i1b+\.u1a+\.[0-9a-f]{16}@gitfor\.dev$/); expect(address?.split("@")[0].length).toBeLessThanOrEqual(64); expect(isReplyAddress(address ?? "")).toBe(true); expect(isReplyAddress("abuse@gitfor.dev")).toBe(false); expect(await replyAddressFor("u2aaaaaaaaaaaaaaaaaa", ISSUE)).not.toBe(address); }); test("are off when no reply domain is configured", async () => { env.EMAIL_REPLY_DOMAIN = ""; expect(await replyAddressFor(USER, ISSUE)).toBeNull(); env.EMAIL_REPLY_DOMAIN = "gitfor.dev"; }); test("default to the sending domain of EMAIL_FROM", async () => { env.EMAIL_REPLY_DOMAIN = ""; Object.assign(env, { EMAIL_FROM: "gitfor " }); expect(await replyAddressFor(USER, ISSUE)).toMatch(/@mail\.gitfor\.dev$/); Object.assign(env, { EMAIL_FROM: "gitfor " }); expect(await replyAddressFor(USER, ISSUE)).toBeNull(); Object.assign(env, { EMAIL_FROM: "" }); env.EMAIL_REPLY_DOMAIN = "gitfor.dev"; }); }); describe("handleReplyEmail", () => { test("posts the reply text, without the quote, as the addressed user", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const { message, result } = inbound( to.toUpperCase(), mime("alice@example.com", "Fixed in main.\r\n\r\nOn Mon, gitfor wrote:\r\n> old"), ); await handleReplyEmail(message); expect(result.rejected).toBeNull(); expect(comments).toEqual([{ issueId: ISSUE, userId: USER, body: "Fixed in main." }]); }); test("rejects a forged or tampered address", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const tampered = to.replace(/\.[0-9a-f]{16}@/, ".0000000000000000@"); const { message, result } = inbound(tampered, mime("alice@example.com", "hi")); await handleReplyEmail(message); expect(result.rejected).toBe("Unknown reply address"); expect(comments).toEqual([]); }); test("rejects mail from an address the user has not verified", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const { message, result } = inbound(to, mime("mallory@example.com", "hi")); await handleReplyEmail(message); expect(result.rejected).toContain("not verified"); expect(comments).toEqual([]); }); test("bounces with the access error when the user lost access", async () => { access = "none"; const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const { message, result } = inbound(to, mime("alice@example.com", "hi")); await handleReplyEmail(message); expect(result.rejected).toBe("Repository not found"); expect(comments).toEqual([]); }); test("bounces empty replies and deleted threads", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const empty = inbound(to, mime("alice@example.com", "> only the quote")); await handleReplyEmail(empty.message); expect(empty.result.rejected).toBe("The reply was empty"); issues.clear(); const gone = inbound(to, mime("alice@example.com", "hi")); await handleReplyEmail(gone.message); expect(gone.result.rejected).toBe("This conversation no longer exists"); expect(comments).toEqual([]); }); test("silently ignores auto-replies so out-of-office mail never loops", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const { message, result } = inbound(to, mime("alice@example.com", "I am away"), { "auto-submitted": "auto-replied", }); await handleReplyEmail(message); expect(result.rejected).toBeNull(); expect(comments).toEqual([]); }); });