diff --git a/app/src/core/drivers/email/plunk.spec.ts b/app/src/core/drivers/email/plunk.spec.ts new file mode 100644 index 0000000..99245f8 --- /dev/null +++ b/app/src/core/drivers/email/plunk.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "bun:test"; +import { plunkEmail } from "./plunk"; + +const ALL_TESTS = !!process.env.ALL_TESTS; + +describe.skipIf(ALL_TESTS)("plunk", () => { + it.only("should throw on failed", async () => { + const driver = plunkEmail({ apiKey: "invalid" }); + expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow(); + }); + + it("should send an email", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: "test@example.com", + }); + const response = await driver.send( + "test@example.com", + "Test Email", + "This is a test email", + ); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + expect(response.emails).toBeDefined(); + expect(response.timestamp).toBeDefined(); + }); + + it("should send HTML email", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: "test@example.com", + }); + const htmlBody = "

Test Email

This is a test email

"; + const response = await driver.send( + "test@example.com", + "HTML Test", + htmlBody, + ); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + }); + + it("should send with text and html", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: "test@example.com", + }); + const response = await driver.send("test@example.com", "Test Email", { + text: "This is plain text", + html: "

This is HTML

", + }); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + }); + + it("should send to multiple recipients", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: "test@example.com", + }); + const response = await driver.send( + "test@example.com", + "Multi-recipient Test", + "Test email to multiple recipients", + { + to: ["test1@example.com", "test2@example.com"], + }, + ); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + expect(response.emails).toHaveLength(2); + }); + + it("should throw error for more than 5 recipients", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: "test@example.com", + }); + expect( + driver.send("test@example.com", "Test", "Test", { + to: [ + "test1@example.com", + "test2@example.com", + "test3@example.com", + "test4@example.com", + "test5@example.com", + "test6@example.com", + ], + }), + ).rejects.toThrow("Plunk supports a maximum of 5 recipients per email"); + }); +}); diff --git a/app/src/core/drivers/email/plunk.ts b/app/src/core/drivers/email/plunk.ts new file mode 100644 index 0000000..6e75734 --- /dev/null +++ b/app/src/core/drivers/email/plunk.ts @@ -0,0 +1,94 @@ +import type { IEmailDriver } from "./index"; + +export type PlunkEmailOptions = { + apiKey: string; + host?: string; + from?: string; +}; + +export type PlunkEmailSendOptions = { + to?: string | string[]; + subscribed?: boolean; + name?: string; + from?: string; + reply?: string; + headers?: Record; +}; + +export type PlunkEmailResponse = { + success: boolean; + emails: Array<{ + contact: { + id: string; + email: string; + }; + email: string; + }>; + timestamp: string; +}; + +export const plunkEmail = ( + config: PlunkEmailOptions, +): IEmailDriver => { + const host = config.host ?? "https://api.useplunk.com/v1/send"; + const from = config.from; + + return { + send: async ( + to: string, + subject: string, + body: string | { text: string; html: string }, + options?: PlunkEmailSendOptions, + ) => { + // Determine recipients - options.to takes precedence if provided + const recipients = options?.to ?? to; + + // Validate recipient count (Plunk max is 5) + const recipientArray = Array.isArray(recipients) + ? recipients + : [recipients]; + if (recipientArray.length > 5) { + throw new Error( + "Plunk supports a maximum of 5 recipients per email", + ); + } + + // Build base payload + const payload: any = { + to: recipients, + subject, + }; + + // Handle body - Plunk only accepts a single body field + if (typeof body === "string") { + payload.body = body; + } else { + // When both text and html are provided, send html (degrades gracefully) + payload.body = body.html; + } + + // Add optional fields from config + if (from) { + payload.from = from; + } + + // Merge with additional options (excluding 'to' since we already handled it) + const { to: _, ...restOptions } = options ?? {}; + + const res = await fetch(host, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ ...payload, ...restOptions }), + }); + + if (!res.ok) { + throw new Error(`Plunk API error: ${await res.text()}`); + } + + return (await res.json()) as PlunkEmailResponse; + }, + }; +}; diff --git a/app/src/core/drivers/index.ts b/app/src/core/drivers/index.ts index da356b7..963b9c4 100644 --- a/app/src/core/drivers/index.ts +++ b/app/src/core/drivers/index.ts @@ -5,3 +5,4 @@ export type { IEmailDriver } from "./email"; export { resendEmail } from "./email/resend"; export { sesEmail } from "./email/ses"; export { mailchannelsEmail } from "./email/mailchannels"; +export { plunkEmail } from "./email/plunk"; diff --git a/tasks/tasks-plunk-email-driver.md b/tasks/tasks-plunk-email-driver.md index 2e6ac5b..4b5dff6 100644 --- a/tasks/tasks-plunk-email-driver.md +++ b/tasks/tasks-plunk-email-driver.md @@ -26,41 +26,41 @@ Update the file after completing each sub-task, not just after completing an ent ## Tasks -- [ ] 0.0 Create feature branch - - [ ] 0.1 Verify current branch (already on `claude/add-loops-email-driver-011CUxMpVqe8AT22gN2k5ZVm`) +- [x] 0.0 Create feature branch + - [x] 0.1 Verify current branch (already on `claude/add-loops-email-driver-011CUxMpVqe8AT22gN2k5ZVm`) -- [ ] 1.0 Implement Plunk email driver - - [ ] 1.1 Read `resend.ts` file to understand the factory pattern and implementation structure - - [ ] 1.2 Create `/home/user/bknd/app/src/core/drivers/email/plunk.ts` file - - [ ] 1.3 Define `PlunkEmailOptions` type with `apiKey`, `host?`, and `from?` fields - - [ ] 1.4 Define `PlunkEmailSendOptions` type with `to?`, `subscribed?`, `name?`, `from?`, `reply?`, and `headers?` fields - - [ ] 1.5 Define `PlunkEmailResponse` type matching Plunk's API response structure (`success`, `emails`, `timestamp`) - - [ ] 1.6 Implement `plunkEmail()` factory function that accepts config and returns `IEmailDriver` instance - - [ ] 1.7 Implement `send()` method with proper body handling (string vs `{ text, html }` object) - - [ ] 1.8 Add recipient validation to ensure max 5 recipients when `options.to` is an array - - [ ] 1.9 Add HTTP request implementation using `fetch()` with proper headers (Authorization, Content-Type) - - [ ] 1.10 Add error handling for failed API responses (check `res.ok` and throw with error text) - - [ ] 1.11 Implement proper payload construction with `to`, `subject`, `body`, and optional fields +- [x] 1.0 Implement Plunk email driver + - [x] 1.1 Read `resend.ts` file to understand the factory pattern and implementation structure + - [x] 1.2 Create `/home/user/bknd/app/src/core/drivers/email/plunk.ts` file + - [x] 1.3 Define `PlunkEmailOptions` type with `apiKey`, `host?`, and `from?` fields + - [x] 1.4 Define `PlunkEmailSendOptions` type with `to?`, `subscribed?`, `name?`, `from?`, `reply?`, and `headers?` fields + - [x] 1.5 Define `PlunkEmailResponse` type matching Plunk's API response structure (`success`, `emails`, `timestamp`) + - [x] 1.6 Implement `plunkEmail()` factory function that accepts config and returns `IEmailDriver` instance + - [x] 1.7 Implement `send()` method with proper body handling (string vs `{ text, html }` object) + - [x] 1.8 Add recipient validation to ensure max 5 recipients when `options.to` is an array + - [x] 1.9 Add HTTP request implementation using `fetch()` with proper headers (Authorization, Content-Type) + - [x] 1.10 Add error handling for failed API responses (check `res.ok` and throw with error text) + - [x] 1.11 Implement proper payload construction with `to`, `subject`, `body`, and optional fields -- [ ] 2.0 Create unit tests for Plunk driver - - [ ] 2.1 Read `resend.spec.ts` to understand test pattern and structure - - [ ] 2.2 Create `/home/user/bknd/app/src/core/drivers/email/plunk.spec.ts` file - - [ ] 2.3 Set up test structure with `describe.skipIf(ALL_TESTS)` wrapper - - [ ] 2.4 Write test: "should throw on failed" - test with invalid API key expecting error - - [ ] 2.5 Write test: "should send an email" - test successful email send (requires `PLUNK_API_KEY` env var) - - [ ] 2.6 Write test: "should send HTML email" - test with HTML string body - - [ ] 2.7 Write test: "should send with text and html" - test with `{ text, html }` body object - - [ ] 2.8 Write test: "should send to multiple recipients" - test with array of recipients in options +- [x] 2.0 Create unit tests for Plunk driver + - [x] 2.1 Read `resend.spec.ts` to understand test pattern and structure + - [x] 2.2 Create `/home/user/bknd/app/src/core/drivers/email/plunk.spec.ts` file + - [x] 2.3 Set up test structure with `describe.skipIf(ALL_TESTS)` wrapper + - [x] 2.4 Write test: "should throw on failed" - test with invalid API key expecting error + - [x] 2.5 Write test: "should send an email" - test successful email send (requires `PLUNK_API_KEY` env var) + - [x] 2.6 Write test: "should send HTML email" - test with HTML string body + - [x] 2.7 Write test: "should send with text and html" - test with `{ text, html }` body object + - [x] 2.8 Write test: "should send to multiple recipients" - test with array of recipients in options -- [ ] 3.0 Update driver exports - - [ ] 3.1 Read current `/home/user/bknd/app/src/core/drivers/index.ts` file - - [ ] 3.2 Add `export { plunkEmail } from "./email/plunk";` to `/home/user/bknd/app/src/core/drivers/index.ts` +- [x] 3.0 Update driver exports + - [x] 3.1 Read current `/home/user/bknd/app/src/core/drivers/index.ts` file + - [x] 3.2 Add `export { plunkEmail } from "./email/plunk";` to `/home/user/bknd/app/src/core/drivers/index.ts` -- [ ] 4.0 Verify implementation and run tests - - [ ] 4.1 Run Plunk driver tests with `bun test app/src/core/drivers/email/plunk.spec.ts` - - [ ] 4.2 Verify all tests pass (or only skip if `ALL_TESTS` is true and no `PLUNK_API_KEY`) - - [ ] 4.3 Review code for consistency with existing drivers (resend.ts pattern) - - [ ] 4.4 Verify TypeScript types are correctly defined and exported +- [x] 4.0 Verify implementation and run tests + - [x] 4.1 Run Plunk driver tests with `bun test app/src/core/drivers/email/plunk.spec.ts` + - [x] 4.2 Verify all tests pass (or only skip if `ALL_TESTS` is true and no `PLUNK_API_KEY`) + - [x] 4.3 Review code for consistency with existing drivers (resend.ts pattern) + - [x] 4.4 Verify TypeScript types are correctly defined and exported - [ ] 5.0 Commit and push changes - [ ] 5.1 Stage all changed files (`plunk.ts`, `plunk.spec.ts`, `index.ts`)