diff --git a/src/__tests__/providers/mailinator.test.ts b/src/__tests__/providers/mailinator.test.ts new file mode 100644 index 0000000..fccf7b4 --- /dev/null +++ b/src/__tests__/providers/mailinator.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mailinatorProvider } from "../../providers/mailinator"; + +vi.stubGlobal("fetch", vi.fn()); + +describe("mailinatorProvider", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("returns correct domain", () => { + const provider = mailinatorProvider(); + expect(provider.domain).toBe("mailinator.com"); + }); + + it("extracts email body successfully", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + msgs: [{ id: "msg-abc-123" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + parts: [{ body: "Your verification code is 847291. It expires in 10 minutes." }], + }), + }); + + vi.stubGlobal("fetch", fetchMock); + + const provider = mailinatorProvider(); + const result = await provider.extractContent({ + email: "testuser@mailinator.com", + prompt: "get the 6 digit verification code", + }); + + expect(result).toContain("847291"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("sends API key in headers when provided", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + msgs: [{ id: "msg-456" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + parts: [{ body: "Click here to reset your password: https://example.com/reset/abc" }], + }), + }); + + vi.stubGlobal("fetch", fetchMock); + + const provider = mailinatorProvider({ apiKey: "my-test-api-key" }); + await provider.extractContent({ + email: "testuser@mailinator.com", + prompt: "get the reset link", + }); + + const inboxRequestHeaders = fetchMock.mock.calls[0]?.[1]?.headers; + expect(inboxRequestHeaders).toEqual({ + Authorization: "Bearer my-test-api-key", + }); + }); + + it("sends no headers when no API key provided", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + msgs: [{ id: "msg-789" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + parts: [{ body: "Welcome! Your code is 123456" }], + }), + }); + + vi.stubGlobal("fetch", fetchMock); + + const provider = mailinatorProvider(); + await provider.extractContent({ + email: "test@mailinator.com", + prompt: "get the code", + }); + + const inboxRequestHeaders = fetchMock.mock.calls[0]?.[1]?.headers; + expect(inboxRequestHeaders ?? {}).toEqual({}); + }); + + it("throws when inbox has no emails", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ msgs: [] }), + })); + + const provider = mailinatorProvider(); + + await expect( + provider.extractContent({ + email: "nobody@mailinator.com", + prompt: "get the code", + }) + ).rejects.toThrow("No emails found for nobody@mailinator.com"); + }); + + it("throws when inbox API call fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: false, + status: 429, + })); + + const provider = mailinatorProvider(); + + await expect( + provider.extractContent({ + email: "test@mailinator.com", + prompt: "get the code", + }) + ).rejects.toThrow("HTTP 429"); + }); + + it("throws when message API call fails", async () => { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + msgs: [{ id: "msg-123" }], + }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + }) + ); + + const provider = mailinatorProvider(); + + await expect( + provider.extractContent({ + email: "test@mailinator.com", + prompt: "get the code", + }) + ).rejects.toThrow("HTTP 500"); + }); + + it("throws when email body is empty", async () => { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + msgs: [{ id: "msg-empty" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + parts: [{ body: "" }], + }), + }) + ); + + const provider = mailinatorProvider(); + + await expect( + provider.extractContent({ + email: "test@mailinator.com", + prompt: "get the code", + }) + ).rejects.toThrow("Email body is empty"); + }); +}); \ No newline at end of file diff --git a/src/providers/mailinator.ts b/src/providers/mailinator.ts new file mode 100644 index 0000000..5b7b60f --- /dev/null +++ b/src/providers/mailinator.ts @@ -0,0 +1,87 @@ +import type { EmailProvider } from "../config"; + +type MailinatorInbox = { + msgs?: Array<{ + id: string; + }>; +}; + +type MailinatorMessage = { + parts?: Array<{ + body?: string; + }>; +}; + +/** + * Mailinator provider for retrieving email content from Mailinator inboxes. + * + * @param options - Configuration options for the Mailinator provider + * @returns An EmailProvider instance + */ +export function mailinatorProvider(options: { + apiKey?: string; +} = {}): EmailProvider { + return { + domain: "mailinator.com", + + extractContent: async ({ email }) => { + const headers: Record = {}; + + if (options.apiKey) { + headers.Authorization = `Bearer ${options.apiKey}`; + } + + const [username] = email.split("@"); + + if (!username) { + throw new Error(`[mailinator] Invalid email: ${email}`); + } + + const inboxResponse = await fetch( + `https://mailinator.com/api/v2/domains/mailinator.com/inboxes/${username}`, + { + headers, + } + ); + + if (!inboxResponse.ok) { + throw new Error( + `[mailinator] Failed to fetch inbox for ${email}: HTTP ${inboxResponse.status}` + ); + } + + const inbox = (await inboxResponse.json()) as MailinatorInbox; + + if (!inbox.msgs || inbox.msgs.length === 0) { + throw new Error( + `[mailinator] No emails found for ${email}` + ); + } + + const latestEmail = inbox.msgs[0]; + const messageResponse = await fetch( + `https://mailinator.com/api/v2/domains/mailinator.com/inboxes/${username}/messages/${latestEmail.id}`, + { + headers, + } + ); + + if (!messageResponse.ok) { + throw new Error( + `[mailinator] Failed to fetch message ${latestEmail.id}: HTTP ${messageResponse.status}` + ); + } + + const message = (await messageResponse.json()) as MailinatorMessage; + const emailBody = message.parts?.[0]?.body ?? ""; + + if (!emailBody) { + throw new Error( + `[mailinator] Email body is empty for ${email}` + ); + } + + return emailBody; + }, + }; +} \ No newline at end of file