diff --git a/app/.env.example b/app/.env.example index a70d8e7..463c8a7 100644 --- a/app/.env.example +++ b/app/.env.example @@ -20,6 +20,7 @@ VITE_SHOW_ROUTES= # ===== Test Credentials ===== RESEND_API_KEY= +PLUNK_API_KEY= R2_TOKEN= R2_ACCESS_KEY= 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..82fb544 --- /dev/null +++ b/app/src/core/drivers/email/plunk.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "bun:test"; +import { plunkEmail } from "./plunk"; + +const ALL_TESTS = !!process.env.ALL_TESTS; + +describe.skipIf(ALL_TESTS)("plunk", () => { + it("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: undefined, // Default to what Plunk sets + }); + const response = await driver.send( + "help@bknd.io", + "Test Email from Plunk", + "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: undefined, + }); + const htmlBody = "

Test Email

This is a test email

"; + const response = await driver.send( + "help@bknd.io", + "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: undefined, + }); + const response = await driver.send("test@example.com", "Test Email", { + text: "help@bknd.io", + html: "

This is HTML

", + }); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + }); +}); diff --git a/app/src/core/drivers/email/plunk.ts b/app/src/core/drivers/email/plunk.ts new file mode 100644 index 0000000..a3c7761 --- /dev/null +++ b/app/src/core/drivers/email/plunk.ts @@ -0,0 +1,70 @@ +import type { IEmailDriver } from "./index"; + +export type PlunkEmailOptions = { + apiKey: string; + host?: string; + from?: string; +}; + +export type PlunkEmailSendOptions = { + 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, + ) => { + const payload: any = { + from, + to, + subject, + }; + + if (typeof body === "string") { + payload.body = body; + } else { + payload.body = body.html; + } + + const res = await fetch(host, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ ...payload, ...options }), + }); + + 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";