From f7a1c2e510bd77cd7c2964bc6a7381f6c803fb7a Mon Sep 17 00:00:00 2001 From: DeWitt Gibson Date: Mon, 6 Apr 2026 13:07:20 -0700 Subject: [PATCH 1/2] Add email-automation plugin with tests Introduce the email-automation plugin package (@agentbase/plugin-email-automation). Adds full implementation (src/index.ts), manifest, package.json, tsconfig files and comprehensive Jest tests. Plugin supports Resend/SendGrid providers, AI-generated copy, transactional sends, drip campaigns, system templates, endpoints, cron jobs and hooks (user:register, conversation:end). Includes unit tests covering DB helpers, interpolation, provider integrations, email sending, campaign management, cron advancement logic and endpoint behaviors. --- .../email-automation/__tests__/index.test.ts | 1467 +++++++++++++++++ .../email-automation/__tests__/tsconfig.json | 14 + .../official/email-automation/manifest.json | 10 + .../official/email-automation/package.json | 41 + .../official/email-automation/src/index.ts | 860 ++++++++++ .../official/email-automation/tsconfig.json | 21 + .../email-automation/tsconfig.test.json | 14 + pnpm-lock.yaml | 199 ++- 8 files changed, 2614 insertions(+), 12 deletions(-) create mode 100644 packages/plugins/official/email-automation/__tests__/index.test.ts create mode 100644 packages/plugins/official/email-automation/__tests__/tsconfig.json create mode 100644 packages/plugins/official/email-automation/manifest.json create mode 100644 packages/plugins/official/email-automation/package.json create mode 100644 packages/plugins/official/email-automation/src/index.ts create mode 100644 packages/plugins/official/email-automation/tsconfig.json create mode 100644 packages/plugins/official/email-automation/tsconfig.test.json diff --git a/packages/plugins/official/email-automation/__tests__/index.test.ts b/packages/plugins/official/email-automation/__tests__/index.test.ts new file mode 100644 index 0000000..9f3bdf2 --- /dev/null +++ b/packages/plugins/official/email-automation/__tests__/index.test.ts @@ -0,0 +1,1467 @@ +/// +/** + * Email Automation — Unit Tests + * + * Covers: DB key helpers, interpolate, sendViaResend, sendViaSendGrid, + * sendEmail, generateEmailCopy, advanceDripSubscribers, SYSTEM_TEMPLATES, + * plugin manifest/settings, app:init (all 9 endpoints + 2 crons), + * user:register hook, and conversation:end hook. + */ +import plugin, { + buildTemplateKey, + buildCampaignKey, + buildSubscriberKey, + buildSentKey, + interpolate, + sendViaResend, + sendViaSendGrid, + sendEmail, + generateEmailCopy, + advanceDripSubscribers, + SYSTEM_TEMPLATES, + RESEND_API_URL, + SENDGRID_API_URL, + AI_COMPLETIONS_PATH, + DEFAULT_PROVIDER, + SUPPORTED_PROVIDERS, + EmailTemplate, + DripCampaign, + SubscriberState, + EmailReceipt, +} from "../src/index"; +import { + PluginContext, + PluginAPI, + PluginDatabaseAPI, + PluginEventBus, + EndpointDefinition, + CronJobDefinition, + EndpointRequest, + EndpointResponse, +} from "@agentbase/plugin-sdk"; + +// ── Mock factory ────────────────────────────────────────────────────────────── + +function createMockAPI(): PluginAPI & { + _endpoints: EndpointDefinition[]; + _crons: CronJobDefinition[]; +} { + const store = new Map(); + const _endpoints: EndpointDefinition[] = []; + const _crons: CronJobDefinition[] = []; + + const db: PluginDatabaseAPI = { + set: jest + .fn() + .mockImplementation(async (k: string, v: unknown) => store.set(k, v)), + get: jest + .fn() + .mockImplementation(async (k: string) => store.get(k) ?? null), + delete: jest.fn().mockImplementation(async (k: string) => { + const had = store.has(k); + store.delete(k); + return had; + }), + keys: jest + .fn() + .mockImplementation(async (prefix?: string) => + [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)), + ), + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }; + + const events: PluginEventBus = { + emit: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + off: jest.fn(), + }; + + const configStore = new Map(); + + return { + _endpoints, + _crons, + getConfig: jest + .fn() + .mockImplementation((key: string) => configStore.get(key) ?? undefined), + setConfig: jest + .fn() + .mockImplementation(async (key: string, value: unknown) => + configStore.set(key, value), + ), + makeRequest: jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }), + log: jest.fn(), + db, + events, + registerEndpoint: jest + .fn() + .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)), + registerCronJob: jest + .fn() + .mockImplementation((def: CronJobDefinition) => _crons.push(def)), + registerWebhook: jest.fn(), + registerAdminPage: jest.fn(), + } as unknown as PluginAPI & { + _endpoints: EndpointDefinition[]; + _crons: CronJobDefinition[]; + }; +} + +type MockAPI = ReturnType; +type MockCtx = PluginContext & { api: MockAPI }; + +function makeCtx(overrides: Partial = {}): MockCtx { + const api = createMockAPI(); + return { appId: "app-1", userId: "user-1", config: {}, api, ...overrides } as MockCtx; +} + +interface MockRes { + status: jest.Mock; + json: jest.Mock; + send: jest.Mock; + _status: number; + _body: unknown; +} + +function makeRes(): MockRes { + const r: MockRes = { + _status: 200, + _body: undefined, + status: jest.fn(), + json: jest.fn(), + send: jest.fn(), + }; + r.status.mockImplementation((code: number) => { + r._status = code; + return r; + }); + r.json.mockImplementation((body: unknown) => { + r._body = body; + }); + return r; +} + +function makeReq(overrides: Partial = {}): EndpointRequest { + return { + method: "GET", + path: "/", + params: {}, + query: {}, + body: {}, + headers: {}, + ...overrides, + }; +} + +async function runInit(ctx: MockCtx): Promise { + const hook = plugin.definition.hooks?.["app:init"]; + if (!hook) throw new Error("app:init hook not registered"); + await hook(ctx); +} + +function getEndpoint( + api: MockAPI, + method: string, + path: string, +): EndpointDefinition { + const ep = api._endpoints.find((e) => e.method === method && e.path === path); + if (!ep) throw new Error(`Endpoint ${method} ${path} not found`); + return ep; +} + +// ── DB Key Helpers ──────────────────────────────────────────────────────────── + +describe("DB key helpers", () => { + it("buildTemplateKey", () => { + expect(buildTemplateKey("welcome")).toBe("template:welcome"); + expect(buildTemplateKey("custom-tpl")).toBe("template:custom-tpl"); + }); + + it("buildCampaignKey", () => { + expect(buildCampaignKey("cmp_123")).toBe("campaign:cmp_123"); + }); + + it("buildSubscriberKey", () => { + expect(buildSubscriberKey("a@b.com", "cmp_1")).toBe( + "subscriber:a@b.com:cmp_1", + ); + }); + + it("buildSentKey", () => { + expect(buildSentKey("msg_abc")).toBe("sent:msg_abc"); + }); +}); + +// ── interpolate ─────────────────────────────────────────────────────────────── + +describe("interpolate", () => { + it("replaces known variables", () => { + expect(interpolate("Hello {{name}}!", { name: "Alice" })).toBe( + "Hello Alice!", + ); + }); + + it("leaves unknown variables unchanged", () => { + expect(interpolate("{{unknown}}", {})).toBe("{{unknown}}"); + }); + + it("replaces multiple variables", () => { + expect( + interpolate("{{a}} + {{b}}", { a: "1", b: "2" }), + ).toBe("1 + 2"); + }); + + it("replaces same variable multiple times", () => { + expect(interpolate("{{x}} and {{x}}", { x: "hello" })).toBe( + "hello and hello", + ); + }); + + it("handles empty template", () => { + expect(interpolate("", { a: "x" })).toBe(""); + }); + + it("handles template with no placeholders", () => { + expect(interpolate("plain text", { a: "x" })).toBe("plain text"); + }); +}); + +// ── SUPPORTED_PROVIDERS / DEFAULT_PROVIDER constants ───────────────────────── + +describe("provider constants", () => { + it("contains resend and sendgrid", () => { + expect(SUPPORTED_PROVIDERS).toContain("resend"); + expect(SUPPORTED_PROVIDERS).toContain("sendgrid"); + }); + + it("default provider is resend", () => { + expect(DEFAULT_PROVIDER).toBe("resend"); + }); +}); + +// ── sendViaResend ───────────────────────────────────────────────────────────── + +describe("sendViaResend", () => { + it("calls Resend API with correct headers and body", async () => { + const mockMake = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "resend_123" }), + }); + + const receipt = await sendViaResend( + mockMake, + "key_abc", + "from@example.com", + "Test From", + { to: "to@example.com", subject: "Hello", html: "

Hi

" }, + ); + + expect(mockMake).toHaveBeenCalledWith( + RESEND_API_URL, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer key_abc", + }), + }), + ); + expect(receipt.messageId).toBe("resend_123"); + expect(receipt.provider).toBe("resend"); + expect(receipt.to).toBe("to@example.com"); + }); + + it("formats from address with name", async () => { + let capturedBody: string | undefined; + const mockMake = jest.fn().mockImplementation(async (_url: string, opts?: RequestInit) => { + capturedBody = opts?.body as string; + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "id1" }), + }; + }); + + await sendViaResend(mockMake, "k", "f@e.com", "My Name", { + to: "t@e.com", + subject: "S", + html: "

H

", + }); + + const parsed = JSON.parse(capturedBody ?? "{}") as Record; + expect(parsed["from"]).toBe("My Name "); + }); + + it("formats from address without name", async () => { + let capturedBody: string | undefined; + const mockMake = jest.fn().mockImplementation(async (_url: string, opts?: RequestInit) => { + capturedBody = opts?.body as string; + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "id2" }), + }; + }); + + await sendViaResend(mockMake, "k", "f@e.com", "", { + to: "t@e.com", + subject: "S", + html: "

H

", + }); + + const parsed = JSON.parse(capturedBody ?? "{}") as Record; + expect(parsed["from"]).toBe("f@e.com"); + }); + + it("throws on non-ok response", async () => { + const mockMake = jest.fn().mockResolvedValue({ ok: false, status: 422 }); + await expect( + sendViaResend(mockMake, "k", "f@e.com", "", { + to: "t@e.com", + subject: "S", + html: "

H

", + }), + ).rejects.toThrow("Resend API error: 422"); + }); +}); + +// ── sendViaSendGrid ─────────────────────────────────────────────────────────── + +describe("sendViaSendGrid", () => { + it("calls SendGrid API with correct structure", async () => { + const mockMake = jest.fn().mockResolvedValue({ ok: true, status: 202 }); + + const receipt = await sendViaSendGrid( + mockMake, + "sg_key", + "from@example.com", + "SG From", + { to: "to@sg.com", subject: "Greet", html: "

Hello

" }, + ); + + expect(mockMake).toHaveBeenCalledWith( + SENDGRID_API_URL, + expect.objectContaining({ method: "POST" }), + ); + expect(receipt.provider).toBe("sendgrid"); + expect(receipt.to).toBe("to@sg.com"); + expect(receipt.messageId).toMatch(/^sg_/); + }); + + it("throws on non-ok response", async () => { + const mockMake = jest.fn().mockResolvedValue({ ok: false, status: 403 }); + await expect( + sendViaSendGrid(mockMake, "k", "f@e.com", "", { + to: "t@e.com", + subject: "S", + html: "

H

", + }), + ).rejects.toThrow("SendGrid API error: 403"); + }); +}); + +// ── sendEmail ───────────────────────────────────────────────────────────────── + +describe("sendEmail", () => { + it("routes to Resend when provider=resend", async () => { + const mockMake = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "r1" }), + }); + const receipt = await sendEmail( + { provider: "resend", apiKey: "key", fromAddress: "f@e.com", fromName: "" }, + mockMake, + { to: "t@e.com", subject: "S", html: "

H

" }, + ); + expect(receipt.provider).toBe("resend"); + expect(mockMake).toHaveBeenCalledWith(RESEND_API_URL, expect.anything()); + }); + + it("routes to SendGrid when provider=sendgrid", async () => { + const mockMake = jest.fn().mockResolvedValue({ ok: true, status: 202 }); + const receipt = await sendEmail( + { provider: "sendgrid", apiKey: "sgkey", fromAddress: "f@e.com", fromName: "" }, + mockMake, + { to: "t@e.com", subject: "S", html: "

H

" }, + ); + expect(receipt.provider).toBe("sendgrid"); + expect(mockMake).toHaveBeenCalledWith(SENDGRID_API_URL, expect.anything()); + }); + + it("defaults to Resend when provider is missing", async () => { + const mockMake = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "r2" }), + }); + const receipt = await sendEmail( + { apiKey: "key", fromAddress: "f@e.com" }, + mockMake, + { to: "t@e.com", subject: "S", html: "

H

" }, + ); + expect(receipt.provider).toBe("resend"); + }); + + it("throws when apiKey is missing", async () => { + const mockMake = jest.fn(); + await expect( + sendEmail({ fromAddress: "f@e.com" }, mockMake, { + to: "t@e.com", + subject: "S", + html: "

H

", + }), + ).rejects.toThrow("API key is not configured"); + }); + + it("throws when fromAddress is missing", async () => { + const mockMake = jest.fn(); + await expect( + sendEmail({ apiKey: "key" }, mockMake, { + to: "t@e.com", + subject: "S", + html: "

H

", + }), + ).rejects.toThrow("fromAddress is not configured"); + }); +}); + +// ── generateEmailCopy ───────────────────────────────────────────────────────── + +describe("generateEmailCopy", () => { + it("returns content from OpenAI-style response", async () => { + const mockMake = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest + .fn() + .mockResolvedValue({ + choices: [{ message: { content: "

Generated copy

" } }], + }), + }); + const result = await generateEmailCopy(mockMake, "Write a welcome email"); + expect(result).toBe("

Generated copy

"); + expect(mockMake).toHaveBeenCalledWith( + AI_COMPLETIONS_PATH, + expect.objectContaining({ method: "POST" }), + ); + }); + + it("returns content from native agentbase response shape", async () => { + const mockMake = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ content: "

Native copy

" }), + }); + const result = await generateEmailCopy(mockMake, "prompt"); + expect(result).toBe("

Native copy

"); + }); + + it("throws on non-ok response", async () => { + const mockMake = jest.fn().mockResolvedValue({ ok: false, status: 503 }); + await expect(generateEmailCopy(mockMake, "p")).rejects.toThrow( + "AI service error: 503", + ); + }); + + it("throws on unexpected response shape", async () => { + const mockMake = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ weird: true }), + }); + await expect(generateEmailCopy(mockMake, "p")).rejects.toThrow( + "Unexpected AI response shape", + ); + }); + + it("passes the model parameter to the AI service", async () => { + let capturedBody: string | undefined; + const mockMake = jest.fn().mockImplementation(async (_url: string, opts?: RequestInit) => { + capturedBody = opts?.body as string; + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ choices: [{ message: { content: "ok" } }] }), + }; + }); + await generateEmailCopy(mockMake, "prompt", "claude-3-5-sonnet"); + const body = JSON.parse(capturedBody ?? "{}") as Record; + expect(body["model"]).toBe("claude-3-5-sonnet"); + }); +}); + +// ── SYSTEM_TEMPLATES ────────────────────────────────────────────────────────── + +describe("SYSTEM_TEMPLATES", () => { + it("contains welcome and conversation-summary", () => { + const slugs = SYSTEM_TEMPLATES.map((t) => t.slug); + expect(slugs).toContain("welcome"); + expect(slugs).toContain("conversation-summary"); + }); + + it("all system templates have isSystem=true", () => { + expect(SYSTEM_TEMPLATES.every((t) => t.isSystem)).toBe(true); + }); + + it("welcome template has required variables", () => { + const welcome = SYSTEM_TEMPLATES.find((t) => t.slug === "welcome")!; + expect(welcome.variables).toContain("appName"); + expect(welcome.variables).toContain("userName"); + }); +}); + +// ── Plugin manifest ─────────────────────────────────────────────────────────── + +describe("plugin manifest / settings", () => { + it("name is email-automation", () => { + expect((plugin.definition as unknown as Record)["name"]).toBe( + "email-automation", + ); + }); + + it("version is 1.0.0", () => { + expect((plugin.definition as unknown as Record)["version"]).toBe( + "1.0.0", + ); + }); + + it("has required settings: provider, apiKey, fromAddress, fromName, sendSummaryOnEnd", () => { + const settings = plugin.definition.settings as Record; + expect(settings).toHaveProperty("provider"); + expect(settings).toHaveProperty("apiKey"); + expect(settings).toHaveProperty("fromAddress"); + expect(settings).toHaveProperty("fromName"); + expect(settings).toHaveProperty("sendSummaryOnEnd"); + }); + + it("apiKey setting has encrypted:true", () => { + const settings = plugin.definition.settings as Record>; + expect(settings["apiKey"]?.["encrypted"]).toBe(true); + }); + + it("provider setting has options resend and sendgrid", () => { + const settings = plugin.definition.settings as Record>; + expect(settings["provider"]?.["options"]).toEqual( + expect.arrayContaining(["resend", "sendgrid"]), + ); + }); +}); + +// ── app:init hook ───────────────────────────────────────────────────────────── + +describe("app:init — seed and registration", () => { + it("seeds system templates into plugin DB", async () => { + const ctx = makeCtx(); + await runInit(ctx); + for (const tpl of SYSTEM_TEMPLATES) { + expect(ctx.api.db.set).toHaveBeenCalledWith( + buildTemplateKey(tpl.slug), + expect.objectContaining({ slug: tpl.slug }), + ); + } + }); + + it("does not overwrite existing templates", async () => { + const ctx = makeCtx(); + // Pre-seed so existing returns truthy + await ctx.api.db.set(buildTemplateKey("welcome"), { existing: true }); + (ctx.api.db.set as jest.Mock).mockClear(); + + await runInit(ctx); + + const welcomeSetCalls = (ctx.api.db.set as jest.Mock).mock.calls.filter( + (c: unknown[]) => c[0] === buildTemplateKey("welcome"), + ); + expect(welcomeSetCalls.length).toBe(0); + }); + + it("registers 9 endpoints", async () => { + const ctx = makeCtx(); + await runInit(ctx); + expect(ctx.api._endpoints.length).toBe(9); + }); + + it("registers 2 cron jobs", async () => { + const ctx = makeCtx(); + await runInit(ctx); + expect(ctx.api._crons.length).toBe(2); + const schedules = ctx.api._crons.map((c) => c.schedule); + expect(schedules).toContain("0 * * * *"); + expect(schedules).toContain("0 8 * * *"); + }); +}); + +// ── POST /send ──────────────────────────────────────────────────────────────── + +describe("POST /send", () => { + async function setup() { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/send"); + return { ctx, ep }; + } + + it("returns 400 when `to` is missing", async () => { + const { ep } = await setup(); + const res = makeRes(); + await ep.handler!(makeReq({ body: { subject: "S", html: "

H

" } }), res as unknown as EndpointResponse); + expect(res._status).toBe(400); + expect(res._body).toMatchObject({ error: expect.stringContaining("to") }); + }); + + it("returns 400 when subject and html are both missing (no templateSlug)", async () => { + const { ep } = await setup(); + const res = makeRes(); + await ep.handler!(makeReq({ body: { to: "a@b.com" } }), res as unknown as EndpointResponse); + expect(res._status).toBe(400); + }); + + it("renders template and sends email", async () => { + const ctx = makeCtx(); + await runInit(ctx); + + // Store a template + const tpl: EmailTemplate = { + slug: "promo", + name: "Promo", + subject: "Hi {{name}}", + body: "

Dear {{name}}

", + variables: ["name"], + createdAt: 1, + updatedAt: 1, + }; + await ctx.api.db.set(buildTemplateKey("promo"), tpl); + + // Configure provider + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + const cfg: Record = { + provider: "resend", + apiKey: "key", + fromAddress: "f@e.com", + fromName: "", + }; + return cfg[k]; + }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "msg_sent" }), + }); + + const ep = getEndpoint(ctx.api, "POST", "/send"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { to: "u@b.com", templateSlug: "promo", vars: { name: "Bob" } } }), + res as unknown as EndpointResponse, + ); + + expect(res._status).toBe(200); + expect(res._body).toMatchObject({ success: true, messageId: "msg_sent" }); + }); + + it("returns 404 when templateSlug not found", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/send"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { to: "u@b.com", templateSlug: "nonexistent" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(404); + }); + + it("returns 500 on provider error", async () => { + const ctx = makeCtx(); + await runInit(ctx); + (ctx.api.getConfig as jest.Mock).mockImplementation(() => "x"); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + }); + const ep = getEndpoint(ctx.api, "POST", "/send"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { to: "u@b.com", subject: "S", html: "

H

" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(500); + }); +}); + +// ── GET /templates ──────────────────────────────────────────────────────────── + +describe("GET /templates", () => { + it("returns all templates", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "GET", "/templates"); + const res = makeRes(); + await ep.handler!(makeReq(), res as unknown as EndpointResponse); + expect(res._status).toBe(200); + const body = res._body as { templates: EmailTemplate[] }; + expect(Array.isArray(body.templates)).toBe(true); + expect(body.templates.length).toBeGreaterThanOrEqual(SYSTEM_TEMPLATES.length); + }); +}); + +// ── POST /templates ─────────────────────────────────────────────────────────── + +describe("POST /templates", () => { + it("creates a new template", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/templates"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { + slug: "my-tpl", + name: "My Template", + subject: "Greet {{name}}", + body: "

Hello {{name}}

", + variables: ["name"], + }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(201); + const body = res._body as { template: EmailTemplate }; + expect(body.template.slug).toBe("my-tpl"); + expect(body.template.isSystem).toBe(false); + }); + + it("returns 400 when required fields are missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/templates"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { slug: "x" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + }); + + it("returns 409 when slug already exists", async () => { + const ctx = makeCtx(); + await runInit(ctx); + // system "welcome" already seeded + const ep = getEndpoint(ctx.api, "POST", "/templates"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { + slug: "welcome", + name: "Dup", + subject: "S", + body: "

B

", + }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(409); + }); +}); + +// ── PUT /templates/:id ──────────────────────────────────────────────────────── + +describe("PUT /templates/:id", () => { + it("updates a custom template", async () => { + const ctx = makeCtx(); + await runInit(ctx); + + // Create a custom template first + const tpl: EmailTemplate = { + slug: "editable", + name: "Old Name", + subject: "Old Subject", + body: "

Old

", + variables: [], + isSystem: false, + createdAt: 1, + updatedAt: 1, + }; + await ctx.api.db.set(buildTemplateKey("editable"), tpl); + + const ep = getEndpoint(ctx.api, "PUT", "/templates/:id"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { id: "editable" }, body: { name: "New Name" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(200); + const body = res._body as { template: EmailTemplate }; + expect(body.template.name).toBe("New Name"); + }); + + it("returns 404 for unknown template", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "PUT", "/templates/:id"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { id: "ghost" }, body: {} }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(404); + }); + + it("returns 403 when trying to modify a system template", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "PUT", "/templates/:id"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { id: "welcome" }, body: { name: "Hacked" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(403); + }); +}); + +// ── POST /campaign ──────────────────────────────────────────────────────────── + +describe("POST /campaign", () => { + it("creates a campaign", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/campaign"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { + name: "Onboarding", + steps: [{ delayHours: 0, templateSlug: "welcome" }], + }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(201); + const body = res._body as { campaign: DripCampaign }; + expect(body.campaign.name).toBe("Onboarding"); + expect(body.campaign.active).toBe(true); + expect(body.campaign.campaignId).toMatch(/^cmp_/); + }); + + it("returns 400 when name is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/campaign"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { steps: [{ delayHours: 0, templateSlug: "welcome" }] } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + }); + + it("returns 400 when steps is empty", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/campaign"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { name: "Empty", steps: [] } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + }); +}); + +// ── GET /campaigns ──────────────────────────────────────────────────────────── + +describe("GET /campaigns", () => { + it("returns list of campaigns", async () => { + const ctx = makeCtx(); + await runInit(ctx); + + const campaign: DripCampaign = { + campaignId: "cmp_test", + name: "Test", + steps: [], + active: true, + createdAt: 1, + updatedAt: 1, + }; + await ctx.api.db.set(buildCampaignKey("cmp_test"), campaign); + + const ep = getEndpoint(ctx.api, "GET", "/campaigns"); + const res = makeRes(); + await ep.handler!(makeReq(), res as unknown as EndpointResponse); + expect(res._status).toBe(200); + const body = res._body as { campaigns: DripCampaign[] }; + expect(body.campaigns.some((c) => c.campaignId === "cmp_test")).toBe(true); + }); +}); + +// ── GET /campaigns/:id/stats ────────────────────────────────────────────────── + +describe("GET /campaigns/:id/stats", () => { + it("returns stats for a valid campaign", async () => { + const ctx = makeCtx(); + await runInit(ctx); + + const campaign: DripCampaign = { + campaignId: "cmp_stat", + name: "Stats Campaign", + steps: [{ delayHours: 1, templateSlug: "welcome" }], + active: true, + createdAt: 1, + updatedAt: 1, + }; + await ctx.api.db.set(buildCampaignKey("cmp_stat"), campaign); + + // Add one subscriber + const sub: SubscriberState = { + campaignId: "cmp_stat", + email: "u@e.com", + currentStep: 0, + nextSendAt: Date.now() + 3_600_000, + joinedAt: Date.now(), + completed: false, + }; + await ctx.api.db.set(buildSubscriberKey("u@e.com", "cmp_stat"), sub); + + const ep = getEndpoint(ctx.api, "GET", "/campaigns/:id/stats"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { id: "cmp_stat" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(200); + const body = res._body as { + totalSubscribers: number; + activeSubscribers: number; + completedSubscribers: number; + }; + expect(body.totalSubscribers).toBe(1); + expect(body.activeSubscribers).toBe(1); + expect(body.completedSubscribers).toBe(0); + }); + + it("returns 404 for unknown campaign", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "GET", "/campaigns/:id/stats"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { id: "ghost" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(404); + }); +}); + +// ── POST /campaign/:id/subscribe ────────────────────────────────────────────── + +describe("POST /campaign/:id/subscribe", () => { + it("subscribes an email to a campaign", async () => { + const ctx = makeCtx(); + await runInit(ctx); + + const campaign: DripCampaign = { + campaignId: "cmp_sub", + name: "Sub Campaign", + steps: [{ delayHours: 24, templateSlug: "welcome" }], + active: true, + createdAt: 1, + updatedAt: 1, + }; + await ctx.api.db.set(buildCampaignKey("cmp_sub"), campaign); + + const ep = getEndpoint(ctx.api, "POST", "/campaign/:id/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { id: "cmp_sub" }, body: { email: "new@user.com" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(201); + const body = res._body as { subscribed: boolean; nextSendAt: number }; + expect(body.subscribed).toBe(true); + expect(body.nextSendAt).toBeGreaterThan(Date.now()); + }); + + it("returns 400 when email is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/campaign/:id/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { id: "x" }, body: {} }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + }); + + it("returns 404 when campaign not found", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/campaign/:id/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { id: "ghost" }, body: { email: "a@b.com" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(404); + }); + + it("returns 409 when email already subscribed", async () => { + const ctx = makeCtx(); + await runInit(ctx); + + const campaign: DripCampaign = { + campaignId: "cmp_dup", + name: "Dup", + steps: [{ delayHours: 1, templateSlug: "welcome" }], + active: true, + createdAt: 1, + updatedAt: 1, + }; + await ctx.api.db.set(buildCampaignKey("cmp_dup"), campaign); + await ctx.api.db.set(buildSubscriberKey("dup@e.com", "cmp_dup"), { existing: true }); + + const ep = getEndpoint(ctx.api, "POST", "/campaign/:id/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { id: "cmp_dup" }, body: { email: "dup@e.com" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(409); + }); +}); + +// ── POST /generate ──────────────────────────────────────────────────────────── + +describe("POST /generate", () => { + it("returns generated content", async () => { + const ctx = makeCtx(); + await runInit(ctx); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ choices: [{ message: { content: "

Generated

" } }] }), + }); + const ep = getEndpoint(ctx.api, "POST", "/generate"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { prompt: "Write a sale email" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(200); + expect((res._body as { content: string }).content).toContain("Generated"); + }); + + it("returns 400 when prompt is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/generate"); + const res = makeRes(); + await ep.handler!(makeReq({ body: {} }), res as unknown as EndpointResponse); + expect(res._status).toBe(400); + }); + + it("returns 500 on AI error", async () => { + const ctx = makeCtx(); + await runInit(ctx); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ ok: false, status: 502 }); + const ep = getEndpoint(ctx.api, "POST", "/generate"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { prompt: "test" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(500); + }); +}); + +// ── advanceDripSubscribers cron ─────────────────────────────────────────────── + +describe("advanceDripSubscribers", () => { + function buildCampaign(id: string): DripCampaign { + return { + campaignId: id, + name: "Test Campaign", + steps: [ + { delayHours: 0, templateSlug: "step1-tpl" }, + { delayHours: 24, templateSlug: "step2-tpl" }, + ], + active: true, + createdAt: 1, + updatedAt: 1, + }; + } + + function buildTemplate(slug: string): EmailTemplate { + return { + slug, + name: slug, + subject: "Step Email", + body: "

Hello {{email}}

", + variables: ["email"], + createdAt: 1, + updatedAt: 1, + }; + } + + it("sends email for due subscriber and advances step", async () => { + const ctx = makeCtx(); + const campaign = buildCampaign("cmp_adv"); + await ctx.api.db.set(buildCampaignKey("cmp_adv"), campaign); + await ctx.api.db.set(buildTemplateKey("step1-tpl"), buildTemplate("step1-tpl")); + await ctx.api.db.set(buildTemplateKey("step2-tpl"), buildTemplate("step2-tpl")); + + const sub: SubscriberState = { + campaignId: "cmp_adv", + email: "user@test.com", + currentStep: 0, + nextSendAt: Date.now() - 1000, // due + joinedAt: Date.now() - 3_600_000, + completed: false, + }; + await ctx.api.db.set(buildSubscriberKey("user@test.com", "cmp_adv"), sub); + + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + const cfg: Record = { + provider: "resend", + apiKey: "key", + fromAddress: "f@e.com", + fromName: "", + }; + return cfg[k]; + }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "drip_msg_1" }), + }); + + const sent = await advanceDripSubscribers(ctx); + expect(sent).toBe(1); + + // Subscriber should have advanced to step 1 + const updated = (await ctx.api.db.get( + buildSubscriberKey("user@test.com", "cmp_adv"), + )) as SubscriberState; + expect(updated.currentStep).toBe(1); + expect(updated.completed).toBe(false); + expect(updated.lastSentAt).toBeDefined(); + }); + + it("skips subscriber whose nextSendAt is in the future", async () => { + const ctx = makeCtx(); + const campaign = buildCampaign("cmp_skip"); + await ctx.api.db.set(buildCampaignKey("cmp_skip"), campaign); + await ctx.api.db.set(buildTemplateKey("step1-tpl"), buildTemplate("step1-tpl")); + + const sub: SubscriberState = { + campaignId: "cmp_skip", + email: "future@e.com", + currentStep: 0, + nextSendAt: Date.now() + 999_999, // not due yet + joinedAt: Date.now(), + completed: false, + }; + await ctx.api.db.set(buildSubscriberKey("future@e.com", "cmp_skip"), sub); + + const sent = await advanceDripSubscribers(ctx); + expect(sent).toBe(0); + }); + + it("marks subscriber complete when no more steps", async () => { + const ctx = makeCtx(); + const singleStepCampaign: DripCampaign = { + campaignId: "cmp_done", + name: "Single Step", + steps: [{ delayHours: 0, templateSlug: "step1-tpl" }], + active: true, + createdAt: 1, + updatedAt: 1, + }; + await ctx.api.db.set(buildCampaignKey("cmp_done"), singleStepCampaign); + await ctx.api.db.set(buildTemplateKey("step1-tpl"), buildTemplate("step1-tpl")); + + const sub: SubscriberState = { + campaignId: "cmp_done", + email: "done@e.com", + currentStep: 0, + nextSendAt: Date.now() - 1000, + joinedAt: Date.now() - 3_600_000, + completed: false, + }; + await ctx.api.db.set(buildSubscriberKey("done@e.com", "cmp_done"), sub); + + (ctx.api.getConfig as jest.Mock).mockReturnValue("key"); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + const cfg: Record = { + provider: "resend", + apiKey: "key", + fromAddress: "f@e.com", + fromName: "", + }; + return cfg[k]; + }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "final_msg" }), + }); + + await advanceDripSubscribers(ctx); + + const updated = (await ctx.api.db.get( + buildSubscriberKey("done@e.com", "cmp_done"), + )) as SubscriberState; + expect(updated.completed).toBe(true); + }); + + it("skips already completed subscribers", async () => { + const ctx = makeCtx(); + const sub: SubscriberState = { + campaignId: "cmp_fin", + email: "fin@e.com", + currentStep: 2, + nextSendAt: Date.now() - 1000, + joinedAt: Date.now() - 10_000, + completed: true, + }; + await ctx.api.db.set(buildSubscriberKey("fin@e.com", "cmp_fin"), sub); + + const sent = await advanceDripSubscribers(ctx); + expect(sent).toBe(0); + expect(ctx.api.makeRequest).not.toHaveBeenCalled(); + }); + + it("skips inactive campaigns", async () => { + const ctx = makeCtx(); + const campaign: DripCampaign = { + campaignId: "cmp_inactive", + name: "Paused", + steps: [{ delayHours: 0, templateSlug: "step1-tpl" }], + active: false, + createdAt: 1, + updatedAt: 1, + }; + await ctx.api.db.set(buildCampaignKey("cmp_inactive"), campaign); + + const sub: SubscriberState = { + campaignId: "cmp_inactive", + email: "p@e.com", + currentStep: 0, + nextSendAt: Date.now() - 1000, + joinedAt: Date.now(), + completed: false, + }; + await ctx.api.db.set(buildSubscriberKey("p@e.com", "cmp_inactive"), sub); + + const sent = await advanceDripSubscribers(ctx); + expect(sent).toBe(0); + }); + + it("logs error and continues when send fails", async () => { + const ctx = makeCtx(); + const campaign = buildCampaign("cmp_err"); + await ctx.api.db.set(buildCampaignKey("cmp_err"), campaign); + await ctx.api.db.set(buildTemplateKey("step1-tpl"), buildTemplate("step1-tpl")); + + const sub: SubscriberState = { + campaignId: "cmp_err", + email: "err@e.com", + currentStep: 0, + nextSendAt: Date.now() - 1000, + joinedAt: Date.now() - 3_600_000, + completed: false, + }; + await ctx.api.db.set(buildSubscriberKey("err@e.com", "cmp_err"), sub); + + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + const cfg: Record = { + provider: "resend", + apiKey: "key", + fromAddress: "f@e.com", + fromName: "", + }; + return cfg[k]; + }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); + + const sent = await advanceDripSubscribers(ctx); + expect(sent).toBe(0); + expect(ctx.api.log).toHaveBeenCalledWith( + expect.stringContaining("err@e.com"), + "error", + ); + }); +}); + +// ── user:register hook ──────────────────────────────────────────────────────── + +describe("user:register hook", () => { + it("sends welcome email when apiKey and fromAddress are configured", async () => { + const ctx = makeCtx(); + // Seed welcome template + await ctx.api.db.set(buildTemplateKey("welcome"), SYSTEM_TEMPLATES[0]!); + + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + const cfg: Record = { + provider: "resend", + apiKey: "key", + fromAddress: "f@e.com", + fromName: "App", + appName: "TestApp", + }; + return cfg[k]; + }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "welcome_msg" }), + }); + + const hook = plugin.definition.hooks?.["user:register"]; + expect(hook).toBeDefined(); + await hook!(ctx, { id: "u1", email: "new@user.com", name: "Alice" }); + + expect(ctx.api.makeRequest).toHaveBeenCalled(); + expect(ctx.api.db.set).toHaveBeenCalledWith( + buildSentKey("welcome_msg"), + expect.any(Object), + ); + }); + + it("skips when apiKey is missing", async () => { + const ctx = makeCtx(); + (ctx.api.getConfig as jest.Mock).mockReturnValue(undefined); + + const hook = plugin.definition.hooks?.["user:register"]; + await hook!(ctx, { email: "u@e.com" }); + + expect(ctx.api.makeRequest).not.toHaveBeenCalled(); + }); + + it("skips when user email is missing", async () => { + const ctx = makeCtx(); + (ctx.api.getConfig as jest.Mock).mockReturnValue("value"); + + const hook = plugin.definition.hooks?.["user:register"]; + await hook!(ctx, {}); + + expect(ctx.api.makeRequest).not.toHaveBeenCalled(); + }); + + it("on send failure, logs error but does not throw", async () => { + const ctx = makeCtx(); + await ctx.api.db.set(buildTemplateKey("welcome"), SYSTEM_TEMPLATES[0]!); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + return k === "apiKey" ? "key" : k === "fromAddress" ? "f@e.com" : undefined; + }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); + + const hook = plugin.definition.hooks?.["user:register"]; + await expect(hook!(ctx, { email: "u@e.com" })).resolves.toBeUndefined(); + expect(ctx.api.log).toHaveBeenCalledWith( + expect.stringContaining("Welcome email failed"), + "error", + ); + }); +}); + +// ── conversation:end hook ───────────────────────────────────────────────────── + +describe("conversation:end hook", () => { + it("sends summary email when sendSummaryOnEnd=true and email provided", async () => { + const ctx = makeCtx(); + await ctx.api.db.set( + buildTemplateKey("conversation-summary"), + SYSTEM_TEMPLATES[1]!, + ); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + const cfg: Record = { + sendSummaryOnEnd: true, + provider: "resend", + apiKey: "key", + fromAddress: "f@e.com", + fromName: "", + }; + return cfg[k]; + }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "summary_msg" }), + }); + + const hook = plugin.definition.hooks?.["conversation:end"]; + expect(hook).toBeDefined(); + await hook!(ctx, { + id: "conv1", + email: "user@e.com", + summary: "We discussed AI topics", + duration: 120, + }); + + expect(ctx.api.makeRequest).toHaveBeenCalled(); + expect(ctx.api.db.set).toHaveBeenCalledWith( + buildSentKey("summary_msg"), + expect.any(Object), + ); + }); + + it("skips when sendSummaryOnEnd=false", async () => { + const ctx = makeCtx(); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => + k === "sendSummaryOnEnd" ? false : "value", + ); + + const hook = plugin.definition.hooks?.["conversation:end"]; + await hook!(ctx, { email: "u@e.com" }); + + expect(ctx.api.makeRequest).not.toHaveBeenCalled(); + }); + + it("skips when conversation email is not provided", async () => { + const ctx = makeCtx(); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => + k === "sendSummaryOnEnd" ? true : "value", + ); + + const hook = plugin.definition.hooks?.["conversation:end"]; + await hook!(ctx, {}); + + expect(ctx.api.makeRequest).not.toHaveBeenCalled(); + }); + + it("logs error on send failure without throwing", async () => { + const ctx = makeCtx(); + await ctx.api.db.set( + buildTemplateKey("conversation-summary"), + SYSTEM_TEMPLATES[1]!, + ); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + const cfg: Record = { + sendSummaryOnEnd: true, + apiKey: "key", + fromAddress: "f@e.com", + }; + return cfg[k]; + }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); + + const hook = plugin.definition.hooks?.["conversation:end"]; + await expect( + hook!(ctx, { email: "u@e.com", summary: "test" }), + ).resolves.toBeUndefined(); + expect(ctx.api.log).toHaveBeenCalledWith( + expect.stringContaining("summary email failed"), + "error", + ); + }); +}); + diff --git a/packages/plugins/official/email-automation/__tests__/tsconfig.json b/packages/plugins/official/email-automation/__tests__/tsconfig.json new file mode 100644 index 0000000..a05feed --- /dev/null +++ b/packages/plugins/official/email-automation/__tests__/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "baseUrl": "..", + "lib": ["ES2022", "DOM"], + "types": ["jest", "node"], + "paths": { + "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"] + } + }, + "include": ["../src/**/*", "./**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugins/official/email-automation/manifest.json b/packages/plugins/official/email-automation/manifest.json new file mode 100644 index 0000000..f081e28 --- /dev/null +++ b/packages/plugins/official/email-automation/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "email-automation", + "version": "1.0.0", + "description": "Transactional emails, drip campaigns, and AI-generated copy via Resend or SendGrid.", + "entryPoint": "dist/index.js", + "author": "Agentbase Team", + "agentbaseVersion": ">=1.0.0", + "permissions": ["network:external", "db:readwrite"], + "peerDependencies": {} +} diff --git a/packages/plugins/official/email-automation/package.json b/packages/plugins/official/email-automation/package.json new file mode 100644 index 0000000..d302df4 --- /dev/null +++ b/packages/plugins/official/email-automation/package.json @@ -0,0 +1,41 @@ +{ + "name": "@agentbase/plugin-email-automation", + "version": "1.0.0", + "description": "Transactional emails, drip campaigns, and AI-generated copy via Resend or SendGrid.", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0-or-later", + "scripts": { + "build": "tsc", + "test": "jest --passWithNoTests", + "test:cov": "jest --coverage --passWithNoTests" + }, + "dependencies": { + "@agentbase/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^25.5.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.0", + "typescript": "^5.7.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": [ + "**/__tests__/**/*.test.ts" + ], + "globals": { + "ts-jest": { + "tsconfig": "./tsconfig.test.json" + } + }, + "coverageThreshold": { + "global": { + "lines": 80 + } + } + } +} diff --git a/packages/plugins/official/email-automation/src/index.ts b/packages/plugins/official/email-automation/src/index.ts new file mode 100644 index 0000000..0e7eb27 --- /dev/null +++ b/packages/plugins/official/email-automation/src/index.ts @@ -0,0 +1,860 @@ +/** + * Email Automation + * + * Transactional emails, drip campaigns, and AI-generated copy via Resend or + * SendGrid. Supports welcome emails on user registration, conversation summary + * emails on session end, custom template management, and time-delayed drip + * campaign sequences. + * + * All external API calls use `makeRequest` (no eval/exec/child_process). + * AI copy generation uses the platform's internal AI completions endpoint. + * + * @package @agentbase/plugin-email-automation + * @version 1.0.0 + */ +import { createPlugin, PluginContext } from "@agentbase/plugin-sdk"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +export const RESEND_API_URL = "https://api.resend.com/emails"; +export const SENDGRID_API_URL = "https://api.sendgrid.com/v3/mail/send"; + +/** Internal platform AI completions endpoint. */ +export const AI_COMPLETIONS_PATH = "/api/v1/internal/ai/completions"; + +export const SUPPORTED_PROVIDERS = ["resend", "sendgrid"] as const; +export type Provider = (typeof SUPPORTED_PROVIDERS)[number]; +export const DEFAULT_PROVIDER: Provider = "resend"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface EmailTemplate { + slug: string; + name: string; + subject: string; + body: string; + variables: string[]; + isSystem?: boolean; + createdAt: number; + updatedAt: number; +} + +export interface DripStep { + /** Hours to wait after previous step (or after subscribe for step 0). */ + delayHours: number; + templateSlug: string; + /** Optional subject override; falls back to template subject. */ + subject?: string; +} + +export interface DripCampaign { + campaignId: string; + name: string; + description?: string; + steps: DripStep[]; + active: boolean; + createdAt: number; + updatedAt: number; +} + +export interface SubscriberState { + campaignId: string; + email: string; + currentStep: number; + /** epoch ms — when to send the next step */ + nextSendAt: number; + joinedAt: number; + completed: boolean; + lastSentAt?: number; +} + +export interface EmailReceipt { + messageId: string; + provider: Provider; + to: string; + subject: string; + sentAt: number; +} + +export interface SendEmailOptions { + to: string; + subject: string; + html: string; + text?: string; +} + +// ── DB Key Helpers ──────────────────────────────────────────────────────────── + +export function buildTemplateKey(slug: string): string { + return `template:${slug}`; +} + +export function buildCampaignKey(id: string): string { + return `campaign:${id}`; +} + +export function buildSubscriberKey(email: string, campaignId: string): string { + return `subscriber:${email}:${campaignId}`; +} + +export function buildSentKey(messageId: string): string { + return `sent:${messageId}`; +} + +// ── Template Engine ─────────────────────────────────────────────────────────── + +/** + * Replace `{{variable}}` placeholders in a template string. + * Unknown variable names are left as-is (returned unchanged). + */ +export function interpolate( + template: string, + vars: Record, +): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => { + return key in vars ? vars[key] : `{{${key}}}`; + }); +} + +// ── Email Provider Adapters ─────────────────────────────────────────────────── + +interface ResendSuccessBody { + id: string; +} + +export async function sendViaResend( + makeRequest: (url: string, opts?: RequestInit) => Promise, + apiKey: string, + fromAddress: string, + fromName: string, + options: SendEmailOptions, +): Promise { + const from = fromName ? `${fromName} <${fromAddress}>` : fromAddress; + const response = await makeRequest(RESEND_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from, + to: [options.to], + subject: options.subject, + html: options.html, + ...(options.text ? { text: options.text } : {}), + }), + }); + + if (!response.ok) { + throw new Error(`Resend API error: ${response.status}`); + } + + const data = (await response.json()) as ResendSuccessBody; + return { + messageId: data.id, + provider: "resend", + to: options.to, + subject: options.subject, + sentAt: Date.now(), + }; +} + +export async function sendViaSendGrid( + makeRequest: (url: string, opts?: RequestInit) => Promise, + apiKey: string, + fromAddress: string, + fromName: string, + options: SendEmailOptions, +): Promise { + const response = await makeRequest(SENDGRID_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: options.to }] }], + from: { email: fromAddress, ...(fromName ? { name: fromName } : {}) }, + subject: options.subject, + content: [{ type: "text/html", value: options.html }], + }), + }); + + if (!response.ok) { + throw new Error(`SendGrid API error: ${response.status}`); + } + + // SendGrid returns 202 No Content — generate a deterministic message ID + const messageId = `sg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + return { + messageId, + provider: "sendgrid", + to: options.to, + subject: options.subject, + sentAt: Date.now(), + }; +} + +/** + * Dispatch a single email using the provider specified in plugin config. + */ +export async function sendEmail( + config: Record, + makeRequest: (url: string, opts?: RequestInit) => Promise, + options: SendEmailOptions, +): Promise { + const provider = (config["provider"] as Provider) ?? DEFAULT_PROVIDER; + const apiKey = (config["apiKey"] as string) ?? ""; + const fromAddress = (config["fromAddress"] as string) ?? ""; + const fromName = (config["fromName"] as string) ?? ""; + + if (!apiKey) throw new Error("Email provider API key is not configured"); + if (!fromAddress) throw new Error("fromAddress is not configured"); + + if (provider === "sendgrid") { + return sendViaSendGrid(makeRequest, apiKey, fromAddress, fromName, options); + } + return sendViaResend(makeRequest, apiKey, fromAddress, fromName, options); +} + +// ── AI Copy Generation ──────────────────────────────────────────────────────── + +/** + * Ask the platform AI service to draft email copy based on a prompt. + * Returns the generated HTML body string. + */ +export async function generateEmailCopy( + makeRequest: (url: string, opts?: RequestInit) => Promise, + prompt: string, + model = "gpt-4o", +): Promise { + const response = await makeRequest(AI_COMPLETIONS_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + messages: [ + { + role: "system", + content: + "You are an expert email copywriter. Write concise, engaging email content. " + + "Return only the HTML body (no / wrapper tags). Use clear formatting.", + }, + { role: "user", content: prompt }, + ], + temperature: 0.7, + }), + }); + + if (!response.ok) throw new Error(`AI service error: ${response.status}`); + + const data = (await response.json()) as Record; + const choices = data["choices"] as Array<{ + message: { content: string }; + }> | undefined; + + if (choices?.[0]?.message?.content) return choices[0].message.content; + if (typeof data["content"] === "string") return data["content"] as string; + throw new Error("Unexpected AI response shape"); +} + +// ── Drip Campaign Cron Handler ──────────────────────────────────────────────── + +/** + * Advance drip campaign subscribers: send the next scheduled step to any + * subscriber whose `nextSendAt` is in the past, then advance their pointer. + * + * @returns Number of emails successfully sent. + */ +export async function advanceDripSubscribers( + context: PluginContext, +): Promise { + const { api } = context; + const now = Date.now(); + let sent = 0; + + const subscriberKeys = await api.db.keys("subscriber:"); + for (const key of subscriberKeys) { + const state = (await api.db.get(key)) as SubscriberState | null; + if (!state || state.completed || state.nextSendAt > now) continue; + + const campaign = (await api.db.get( + buildCampaignKey(state.campaignId), + )) as DripCampaign | null; + if (!campaign || !campaign.active) continue; + + const step = campaign.steps[state.currentStep]; + if (!step) { + await api.db.set(key, { ...state, completed: true } as SubscriberState); + continue; + } + + const template = (await api.db.get( + buildTemplateKey(step.templateSlug), + )) as EmailTemplate | null; + if (!template) continue; + + const subject = + step.subject ?? + interpolate(template.subject, { email: state.email }); + const html = interpolate(template.body, { email: state.email }); + + try { + const receipt = await sendEmail( + { + provider: api.getConfig("provider") as unknown, + apiKey: api.getConfig("apiKey") as unknown, + fromAddress: api.getConfig("fromAddress") as unknown, + fromName: api.getConfig("fromName") as unknown, + }, + api.makeRequest, + { to: state.email, subject, html }, + ); + + await api.db.set(buildSentKey(receipt.messageId), receipt); + + const nextIndex = state.currentStep + 1; + const nextStep = campaign.steps[nextIndex]; + const updated: SubscriberState = { + ...state, + currentStep: nextIndex, + nextSendAt: nextStep ? now + nextStep.delayHours * 3_600_000 : 0, + completed: !nextStep, + lastSentAt: now, + }; + await api.db.set(key, updated); + sent++; + } catch (err) { + api.log( + `Drip send failed for ${state.email}: ${(err as Error).message}`, + "error", + ); + } + } + + return sent; +} + +// ── Built-in System Templates ───────────────────────────────────────────────── + +export const SYSTEM_TEMPLATES: EmailTemplate[] = [ + { + slug: "welcome", + name: "Welcome Email", + subject: "Welcome to {{appName}}!", + body: + "

Welcome, {{userName}}!

" + + "

Thanks for joining {{appName}}. " + + "We're excited to have you on board.

" + + "

— The {{appName}} Team

", + variables: ["appName", "userName"], + isSystem: true, + createdAt: 0, + updatedAt: 0, + }, + { + slug: "conversation-summary", + name: "Conversation Summary", + subject: "Your conversation summary", + body: + "

Conversation Summary

" + + "

Date: {{date}}

" + + "

Duration: {{duration}}

" + + "

{{summary}}

", + variables: ["date", "duration", "summary"], + isSystem: true, + createdAt: 0, + updatedAt: 0, + }, +]; + +// ── Plugin Definition ───────────────────────────────────────────────────────── + +export default createPlugin({ + name: "email-automation", + version: "1.0.0", + description: + "Transactional emails, drip campaigns, and AI-generated copy via Resend or SendGrid.", + permissions: ["network:external", "db:readwrite"], + settings: { + provider: { + type: "select", + label: "Email Provider", + default: "resend", + options: ["resend", "sendgrid"], + }, + apiKey: { + type: "string", + label: "Provider API Key", + encrypted: true, + }, + fromAddress: { + type: "string", + label: "From Address", + }, + fromName: { + type: "string", + label: "From Name", + }, + sendSummaryOnEnd: { + type: "boolean", + label: "Send Conversation Summary on End", + default: false, + }, + }, + + hooks: { + "app:init": async (context) => { + const { api } = context; + + // Seed system templates if absent + for (const tpl of SYSTEM_TEMPLATES) { + const existing = await api.db.get(buildTemplateKey(tpl.slug)); + if (!existing) { + await api.db.set(buildTemplateKey(tpl.slug), tpl); + } + } + + // ── POST /send ────────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/send", + auth: true, + description: "Send a transactional email", + handler: async (req, res) => { + const { to, subject, html, text, templateSlug, vars } = + req.body as { + to?: string; + subject?: string; + html?: string; + text?: string; + templateSlug?: string; + vars?: Record; + }; + + if (!to) { + res.status(400).json({ error: "`to` is required" }); + return; + } + + let finalSubject = subject ?? ""; + let finalHtml = html ?? ""; + + if (templateSlug) { + const tpl = (await api.db.get( + buildTemplateKey(templateSlug), + )) as EmailTemplate | null; + if (!tpl) { + res + .status(404) + .json({ error: `Template '${templateSlug}' not found` }); + return; + } + finalSubject = interpolate(tpl.subject, vars ?? {}); + finalHtml = interpolate(tpl.body, vars ?? {}); + } + + if (!finalSubject || !finalHtml) { + res.status(400).json({ + error: "subject and html (or templateSlug) are required", + }); + return; + } + + try { + const receipt = await sendEmail( + { + provider: api.getConfig("provider"), + apiKey: api.getConfig("apiKey"), + fromAddress: api.getConfig("fromAddress"), + fromName: api.getConfig("fromName"), + }, + api.makeRequest, + { to, subject: finalSubject, html: finalHtml, text }, + ); + await api.db.set(buildSentKey(receipt.messageId), receipt); + res.status(200).json({ success: true, messageId: receipt.messageId }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }, + }); + + // ── GET /templates ────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "GET", + path: "/templates", + auth: true, + description: "List all email templates", + handler: async (_req, res) => { + const keys = await api.db.keys("template:"); + const templates = await Promise.all( + keys.map((k) => api.db.get(k) as Promise), + ); + res.status(200).json({ templates: templates.filter(Boolean) }); + }, + }); + + // ── POST /templates ───────────────────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/templates", + auth: true, + description: "Create a custom email template", + handler: async (req, res) => { + const { slug, name, subject, body, variables } = + req.body as Partial; + + if (!slug || !name || !subject || !body) { + res.status(400).json({ + error: "slug, name, subject, and body are required", + }); + return; + } + + const existing = await api.db.get(buildTemplateKey(slug)); + if (existing) { + res + .status(409) + .json({ error: `Template '${slug}' already exists` }); + return; + } + + const now = Date.now(); + const template: EmailTemplate = { + slug, + name, + subject, + body, + variables: variables ?? [], + isSystem: false, + createdAt: now, + updatedAt: now, + }; + await api.db.set(buildTemplateKey(slug), template); + res.status(201).json({ template }); + }, + }); + + // ── PUT /templates/:id ────────────────────────────────────────────────── + api.registerEndpoint({ + method: "PUT", + path: "/templates/:id", + auth: true, + description: "Update an existing email template", + handler: async (req, res) => { + const { id } = req.params; + const existing = (await api.db.get( + buildTemplateKey(id), + )) as EmailTemplate | null; + + if (!existing) { + res.status(404).json({ error: `Template '${id}' not found` }); + return; + } + if (existing.isSystem) { + res + .status(403) + .json({ error: "System templates cannot be modified" }); + return; + } + + const { name, subject, body, variables } = + req.body as Partial; + const updated: EmailTemplate = { + ...existing, + name: name ?? existing.name, + subject: subject ?? existing.subject, + body: body ?? existing.body, + variables: variables ?? existing.variables, + updatedAt: Date.now(), + }; + await api.db.set(buildTemplateKey(id), updated); + res.status(200).json({ template: updated }); + }, + }); + + // ── POST /campaign ────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/campaign", + auth: true, + description: "Create a drip campaign", + handler: async (req, res) => { + const { name, description, steps } = + req.body as Partial; + + if (!name || !steps || steps.length === 0) { + res + .status(400) + .json({ error: "name and steps[] are required" }); + return; + } + + const now = Date.now(); + const campaignId = `cmp_${now}_${Math.random() + .toString(36) + .slice(2, 9)}`; + const campaign: DripCampaign = { + campaignId, + name, + description, + steps, + active: true, + createdAt: now, + updatedAt: now, + }; + await api.db.set(buildCampaignKey(campaignId), campaign); + res.status(201).json({ campaign }); + }, + }); + + // ── GET /campaigns ────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "GET", + path: "/campaigns", + auth: true, + description: "List all drip campaigns", + handler: async (_req, res) => { + const keys = await api.db.keys("campaign:"); + const campaigns = await Promise.all( + keys.map((k) => api.db.get(k) as Promise), + ); + res.status(200).json({ campaigns: campaigns.filter(Boolean) }); + }, + }); + + // ── GET /campaigns/:id/stats ──────────────────────────────────────────── + api.registerEndpoint({ + method: "GET", + path: "/campaigns/:id/stats", + auth: true, + description: "Get subscriber statistics for a drip campaign", + handler: async (req, res) => { + const { id } = req.params; + const campaign = (await api.db.get( + buildCampaignKey(id), + )) as DripCampaign | null; + + if (!campaign) { + res.status(404).json({ error: `Campaign '${id}' not found` }); + return; + } + + const subKeys = await api.db.keys("subscriber:"); + const allSubs = await Promise.all( + subKeys.map( + (k) => api.db.get(k) as Promise, + ), + ); + const campaignSubs = allSubs.filter( + (s): s is SubscriberState => !!s && s.campaignId === id, + ); + + const total = campaignSubs.length; + const completed = campaignSubs.filter((s) => s.completed).length; + + res.status(200).json({ + campaignId: id, + name: campaign.name, + totalSubscribers: total, + activeSubscribers: total - completed, + completedSubscribers: completed, + steps: campaign.steps.length, + }); + }, + }); + + // ── POST /campaign/:id/subscribe ──────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/campaign/:id/subscribe", + auth: true, + description: "Subscribe an email address to a drip campaign", + handler: async (req, res) => { + const { id } = req.params; + const { email } = req.body as { email?: string }; + + if (!email) { + res.status(400).json({ error: "`email` is required" }); + return; + } + + const campaign = (await api.db.get( + buildCampaignKey(id), + )) as DripCampaign | null; + if (!campaign) { + res.status(404).json({ error: `Campaign '${id}' not found` }); + return; + } + + const subKey = buildSubscriberKey(email, id); + const existing = await api.db.get(subKey); + if (existing) { + res.status(409).json({ error: "Email is already subscribed" }); + return; + } + + const now = Date.now(); + const firstStep = campaign.steps[0]; + const state: SubscriberState = { + campaignId: id, + email, + currentStep: 0, + nextSendAt: firstStep + ? now + firstStep.delayHours * 3_600_000 + : now, + joinedAt: now, + completed: campaign.steps.length === 0, + }; + await api.db.set(subKey, state); + res.status(201).json({ subscribed: true, nextSendAt: state.nextSendAt }); + }, + }); + + // ── POST /generate ────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/generate", + auth: true, + description: "Generate email copy using AI", + handler: async (req, res) => { + const { prompt, model } = req.body as { + prompt?: string; + model?: string; + }; + if (!prompt) { + res.status(400).json({ error: "`prompt` is required" }); + return; + } + try { + const content = await generateEmailCopy( + api.makeRequest, + prompt, + model ?? "gpt-4o", + ); + res.status(200).json({ content }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }, + }); + + // ── Cron: advance drip subscribers (hourly) ───────────────────────────── + api.registerCronJob({ + name: "email-automation:advance-drip", + schedule: "0 * * * *", + handler: async (ctx) => { + await advanceDripSubscribers(ctx); + }, + }); + + // ── Cron: daily digest (8 AM UTC) ─────────────────────────────────────── + api.registerCronJob({ + name: "email-automation:daily-digest", + schedule: "0 8 * * *", + handler: async (ctx) => { + ctx.api.log( + "Daily digest cron triggered (no-op unless configured)", + "info", + ); + }, + }); + }, + + // ── user:register hook ──────────────────────────────────────────────────── + "user:register": async ( + context, + user: { id?: string; email?: string; name?: string }, + ) => { + const { api } = context; + const apiKey = (api.getConfig("apiKey") as string) ?? ""; + const fromAddress = (api.getConfig("fromAddress") as string) ?? ""; + if (!apiKey || !fromAddress || !user?.email) return; + + const tpl = (await api.db.get( + buildTemplateKey("welcome"), + )) as EmailTemplate | null; + if (!tpl) return; + + const appName = (api.getConfig("appName") as string) ?? "Agentbase"; + const subject = interpolate(tpl.subject, { appName }); + const html = interpolate(tpl.body, { + appName, + userName: user.name ?? user.email, + }); + + try { + const receipt = await sendEmail( + { + provider: api.getConfig("provider"), + apiKey, + fromAddress, + fromName: api.getConfig("fromName"), + }, + api.makeRequest, + { to: user.email, subject, html }, + ); + await api.db.set(buildSentKey(receipt.messageId), receipt); + } catch (err) { + api.log( + `Welcome email failed for ${user.email}: ${(err as Error).message}`, + "error", + ); + } + }, + + // ── conversation:end hook ───────────────────────────────────────────────── + "conversation:end": async ( + context, + conversation: { + id?: string; + email?: string; + summary?: string; + duration?: number; + }, + ) => { + const { api } = context; + const sendSummaryOnEnd = + (api.getConfig("sendSummaryOnEnd") as boolean) ?? false; + if (!sendSummaryOnEnd) return; + + const apiKey = (api.getConfig("apiKey") as string) ?? ""; + const fromAddress = (api.getConfig("fromAddress") as string) ?? ""; + if (!apiKey || !fromAddress || !conversation?.email) return; + + const tpl = (await api.db.get( + buildTemplateKey("conversation-summary"), + )) as EmailTemplate | null; + if (!tpl) return; + + const date = new Date().toLocaleDateString(); + const duration = conversation.duration + ? `${Math.ceil(conversation.duration / 60)} minute(s)` + : "unknown"; + const summary = conversation.summary ?? "(no summary available)"; + + const subject = interpolate(tpl.subject, {}); + const html = interpolate(tpl.body, { date, duration, summary }); + + try { + const receipt = await sendEmail( + { + provider: api.getConfig("provider"), + apiKey, + fromAddress, + fromName: api.getConfig("fromName"), + }, + api.makeRequest, + { to: conversation.email, subject, html }, + ); + await api.db.set(buildSentKey(receipt.messageId), receipt); + } catch (err) { + api.log( + `Conversation summary email failed: ${(err as Error).message}`, + "error", + ); + } + }, + }, +}); diff --git a/packages/plugins/official/email-automation/tsconfig.json b/packages/plugins/official/email-automation/tsconfig.json new file mode 100644 index 0000000..7f8ba5d --- /dev/null +++ b/packages/plugins/official/email-automation/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./dist", + "rootDir": "../..", + "baseUrl": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noEmit": true, + "types": ["node"], + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/packages/plugins/official/email-automation/tsconfig.test.json b/packages/plugins/official/email-automation/tsconfig.test.json new file mode 100644 index 0000000..1801816 --- /dev/null +++ b/packages/plugins/official/email-automation/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "baseUrl": ".", + "lib": ["ES2022", "DOM"], + "types": ["jest", "node"], + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["src/**/*", "__tests__/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df549a6..c8b0137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,7 +269,7 @@ importers: version: 10.4.24(postcss@8.5.6) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + version: 29.7.0(@types/node@22.19.11) jest-environment-jsdom: specifier: ^30.2.0 version: 30.2.0 @@ -284,7 +284,7 @@ importers: version: 1.0.7(tailwindcss@3.4.19) ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -373,10 +373,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0 ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -392,10 +392,32 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0 ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + + packages/plugins/official/email-automation: + dependencies: + '@agentbase/plugin-sdk': + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@25.5.2) + ts-jest: + specifier: ^29.2.0 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -414,10 +436,10 @@ importers: version: 25.5.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -433,10 +455,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0 ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -454,10 +476,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0 ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -9477,6 +9499,44 @@ snapshots: - babel-plugin-macros - supports-color + jest-cli@29.7.0: + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-cli@29.7.0(@types/node@22.19.11): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) @@ -9496,6 +9556,25 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@25.5.2): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) @@ -9911,6 +9990,30 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest@29.7.0: + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest@29.7.0(@types/node@22.19.11): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.19.11) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) @@ -9923,6 +10026,18 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@25.5.2): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@25.5.2) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) @@ -12020,6 +12135,26 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.0) jest-util: 30.3.0 + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@22.19.11) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@jest/types': 30.3.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + jest-util: 30.3.0 + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 @@ -12040,6 +12175,46 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.0) jest-util: 30.3.0 + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@25.5.2) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@jest/types': 30.3.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + jest-util: 30.3.0 + + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@jest/types': 30.3.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + jest-util: 30.3.0 + ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 From 5601c8d63811b419c392a0333955802f2ebe9f4b Mon Sep 17 00:00:00 2001 From: DeWitt Gibson Date: Mon, 6 Apr 2026 13:09:25 -0700 Subject: [PATCH 2/2] Format email-automation tests and source Reformat code and tests in the email-automation plugin for improved readability. Changes include consistent object/array formatting, wrapped long lines, normalized mock implementations in tests, and minor multiline type/variable formatting in src/index.ts. No functional or behavioral changes intended. --- .../email-automation/__tests__/index.test.ts | 199 ++++++++++++------ .../official/email-automation/src/index.ts | 40 ++-- 2 files changed, 158 insertions(+), 81 deletions(-) diff --git a/packages/plugins/official/email-automation/__tests__/index.test.ts b/packages/plugins/official/email-automation/__tests__/index.test.ts index 9f3bdf2..eea6962 100644 --- a/packages/plugins/official/email-automation/__tests__/index.test.ts +++ b/packages/plugins/official/email-automation/__tests__/index.test.ts @@ -118,7 +118,13 @@ type MockCtx = PluginContext & { api: MockAPI }; function makeCtx(overrides: Partial = {}): MockCtx { const api = createMockAPI(); - return { appId: "app-1", userId: "user-1", config: {}, api, ...overrides } as MockCtx; + return { + appId: "app-1", + userId: "user-1", + config: {}, + api, + ...overrides, + } as MockCtx; } interface MockRes { @@ -212,9 +218,7 @@ describe("interpolate", () => { }); it("replaces multiple variables", () => { - expect( - interpolate("{{a}} + {{b}}", { a: "1", b: "2" }), - ).toBe("1 + 2"); + expect(interpolate("{{a}} + {{b}}", { a: "1", b: "2" })).toBe("1 + 2"); }); it("replaces same variable multiple times", () => { @@ -279,14 +283,16 @@ describe("sendViaResend", () => { it("formats from address with name", async () => { let capturedBody: string | undefined; - const mockMake = jest.fn().mockImplementation(async (_url: string, opts?: RequestInit) => { - capturedBody = opts?.body as string; - return { - ok: true, - status: 200, - json: jest.fn().mockResolvedValue({ id: "id1" }), - }; - }); + const mockMake = jest + .fn() + .mockImplementation(async (_url: string, opts?: RequestInit) => { + capturedBody = opts?.body as string; + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "id1" }), + }; + }); await sendViaResend(mockMake, "k", "f@e.com", "My Name", { to: "t@e.com", @@ -300,14 +306,16 @@ describe("sendViaResend", () => { it("formats from address without name", async () => { let capturedBody: string | undefined; - const mockMake = jest.fn().mockImplementation(async (_url: string, opts?: RequestInit) => { - capturedBody = opts?.body as string; - return { - ok: true, - status: 200, - json: jest.fn().mockResolvedValue({ id: "id2" }), - }; - }); + const mockMake = jest + .fn() + .mockImplementation(async (_url: string, opts?: RequestInit) => { + capturedBody = opts?.body as string; + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ id: "id2" }), + }; + }); await sendViaResend(mockMake, "k", "f@e.com", "", { to: "t@e.com", @@ -376,7 +384,12 @@ describe("sendEmail", () => { json: jest.fn().mockResolvedValue({ id: "r1" }), }); const receipt = await sendEmail( - { provider: "resend", apiKey: "key", fromAddress: "f@e.com", fromName: "" }, + { + provider: "resend", + apiKey: "key", + fromAddress: "f@e.com", + fromName: "", + }, mockMake, { to: "t@e.com", subject: "S", html: "

H

" }, ); @@ -387,7 +400,12 @@ describe("sendEmail", () => { it("routes to SendGrid when provider=sendgrid", async () => { const mockMake = jest.fn().mockResolvedValue({ ok: true, status: 202 }); const receipt = await sendEmail( - { provider: "sendgrid", apiKey: "sgkey", fromAddress: "f@e.com", fromName: "" }, + { + provider: "sendgrid", + apiKey: "sgkey", + fromAddress: "f@e.com", + fromName: "", + }, mockMake, { to: "t@e.com", subject: "S", html: "

H

" }, ); @@ -439,11 +457,9 @@ describe("generateEmailCopy", () => { const mockMake = jest.fn().mockResolvedValue({ ok: true, status: 200, - json: jest - .fn() - .mockResolvedValue({ - choices: [{ message: { content: "

Generated copy

" } }], - }), + json: jest.fn().mockResolvedValue({ + choices: [{ message: { content: "

Generated copy

" } }], + }), }); const result = await generateEmailCopy(mockMake, "Write a welcome email"); expect(result).toBe("

Generated copy

"); @@ -483,14 +499,18 @@ describe("generateEmailCopy", () => { it("passes the model parameter to the AI service", async () => { let capturedBody: string | undefined; - const mockMake = jest.fn().mockImplementation(async (_url: string, opts?: RequestInit) => { - capturedBody = opts?.body as string; - return { - ok: true, - status: 200, - json: jest.fn().mockResolvedValue({ choices: [{ message: { content: "ok" } }] }), - }; - }); + const mockMake = jest + .fn() + .mockImplementation(async (_url: string, opts?: RequestInit) => { + capturedBody = opts?.body as string; + return { + ok: true, + status: 200, + json: jest + .fn() + .mockResolvedValue({ choices: [{ message: { content: "ok" } }] }), + }; + }); await generateEmailCopy(mockMake, "prompt", "claude-3-5-sonnet"); const body = JSON.parse(capturedBody ?? "{}") as Record; expect(body["model"]).toBe("claude-3-5-sonnet"); @@ -521,15 +541,15 @@ describe("SYSTEM_TEMPLATES", () => { describe("plugin manifest / settings", () => { it("name is email-automation", () => { - expect((plugin.definition as unknown as Record)["name"]).toBe( - "email-automation", - ); + expect( + (plugin.definition as unknown as Record)["name"], + ).toBe("email-automation"); }); it("version is 1.0.0", () => { - expect((plugin.definition as unknown as Record)["version"]).toBe( - "1.0.0", - ); + expect( + (plugin.definition as unknown as Record)["version"], + ).toBe("1.0.0"); }); it("has required settings: provider, apiKey, fromAddress, fromName, sendSummaryOnEnd", () => { @@ -542,12 +562,18 @@ describe("plugin manifest / settings", () => { }); it("apiKey setting has encrypted:true", () => { - const settings = plugin.definition.settings as Record>; + const settings = plugin.definition.settings as Record< + string, + Record + >; expect(settings["apiKey"]?.["encrypted"]).toBe(true); }); it("provider setting has options resend and sendgrid", () => { - const settings = plugin.definition.settings as Record>; + const settings = plugin.definition.settings as Record< + string, + Record + >; expect(settings["provider"]?.["options"]).toEqual( expect.arrayContaining(["resend", "sendgrid"]), ); @@ -611,7 +637,10 @@ describe("POST /send", () => { it("returns 400 when `to` is missing", async () => { const { ep } = await setup(); const res = makeRes(); - await ep.handler!(makeReq({ body: { subject: "S", html: "

H

" } }), res as unknown as EndpointResponse); + await ep.handler!( + makeReq({ body: { subject: "S", html: "

H

" } }), + res as unknown as EndpointResponse, + ); expect(res._status).toBe(400); expect(res._body).toMatchObject({ error: expect.stringContaining("to") }); }); @@ -619,7 +648,10 @@ describe("POST /send", () => { it("returns 400 when subject and html are both missing (no templateSlug)", async () => { const { ep } = await setup(); const res = makeRes(); - await ep.handler!(makeReq({ body: { to: "a@b.com" } }), res as unknown as EndpointResponse); + await ep.handler!( + makeReq({ body: { to: "a@b.com" } }), + res as unknown as EndpointResponse, + ); expect(res._status).toBe(400); }); @@ -658,7 +690,9 @@ describe("POST /send", () => { const ep = getEndpoint(ctx.api, "POST", "/send"); const res = makeRes(); await ep.handler!( - makeReq({ body: { to: "u@b.com", templateSlug: "promo", vars: { name: "Bob" } } }), + makeReq({ + body: { to: "u@b.com", templateSlug: "promo", vars: { name: "Bob" } }, + }), res as unknown as EndpointResponse, ); @@ -708,7 +742,9 @@ describe("GET /templates", () => { expect(res._status).toBe(200); const body = res._body as { templates: EmailTemplate[] }; expect(Array.isArray(body.templates)).toBe(true); - expect(body.templates.length).toBeGreaterThanOrEqual(SYSTEM_TEMPLATES.length); + expect(body.templates.length).toBeGreaterThanOrEqual( + SYSTEM_TEMPLATES.length, + ); }); }); @@ -857,7 +893,9 @@ describe("POST /campaign", () => { const ep = getEndpoint(ctx.api, "POST", "/campaign"); const res = makeRes(); await ep.handler!( - makeReq({ body: { steps: [{ delayHours: 0, templateSlug: "welcome" }] } }), + makeReq({ + body: { steps: [{ delayHours: 0, templateSlug: "welcome" }] }, + }), res as unknown as EndpointResponse, ); expect(res._status).toBe(400); @@ -1026,7 +1064,9 @@ describe("POST /campaign/:id/subscribe", () => { updatedAt: 1, }; await ctx.api.db.set(buildCampaignKey("cmp_dup"), campaign); - await ctx.api.db.set(buildSubscriberKey("dup@e.com", "cmp_dup"), { existing: true }); + await ctx.api.db.set(buildSubscriberKey("dup@e.com", "cmp_dup"), { + existing: true, + }); const ep = getEndpoint(ctx.api, "POST", "/campaign/:id/subscribe"); const res = makeRes(); @@ -1047,7 +1087,9 @@ describe("POST /generate", () => { (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ ok: true, status: 200, - json: jest.fn().mockResolvedValue({ choices: [{ message: { content: "

Generated

" } }] }), + json: jest.fn().mockResolvedValue({ + choices: [{ message: { content: "

Generated

" } }], + }), }); const ep = getEndpoint(ctx.api, "POST", "/generate"); const res = makeRes(); @@ -1064,14 +1106,20 @@ describe("POST /generate", () => { await runInit(ctx); const ep = getEndpoint(ctx.api, "POST", "/generate"); const res = makeRes(); - await ep.handler!(makeReq({ body: {} }), res as unknown as EndpointResponse); + await ep.handler!( + makeReq({ body: {} }), + res as unknown as EndpointResponse, + ); expect(res._status).toBe(400); }); it("returns 500 on AI error", async () => { const ctx = makeCtx(); await runInit(ctx); - (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ ok: false, status: 502 }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: false, + status: 502, + }); const ep = getEndpoint(ctx.api, "POST", "/generate"); const res = makeRes(); await ep.handler!( @@ -1115,8 +1163,14 @@ describe("advanceDripSubscribers", () => { const ctx = makeCtx(); const campaign = buildCampaign("cmp_adv"); await ctx.api.db.set(buildCampaignKey("cmp_adv"), campaign); - await ctx.api.db.set(buildTemplateKey("step1-tpl"), buildTemplate("step1-tpl")); - await ctx.api.db.set(buildTemplateKey("step2-tpl"), buildTemplate("step2-tpl")); + await ctx.api.db.set( + buildTemplateKey("step1-tpl"), + buildTemplate("step1-tpl"), + ); + await ctx.api.db.set( + buildTemplateKey("step2-tpl"), + buildTemplate("step2-tpl"), + ); const sub: SubscriberState = { campaignId: "cmp_adv", @@ -1159,7 +1213,10 @@ describe("advanceDripSubscribers", () => { const ctx = makeCtx(); const campaign = buildCampaign("cmp_skip"); await ctx.api.db.set(buildCampaignKey("cmp_skip"), campaign); - await ctx.api.db.set(buildTemplateKey("step1-tpl"), buildTemplate("step1-tpl")); + await ctx.api.db.set( + buildTemplateKey("step1-tpl"), + buildTemplate("step1-tpl"), + ); const sub: SubscriberState = { campaignId: "cmp_skip", @@ -1186,7 +1243,10 @@ describe("advanceDripSubscribers", () => { updatedAt: 1, }; await ctx.api.db.set(buildCampaignKey("cmp_done"), singleStepCampaign); - await ctx.api.db.set(buildTemplateKey("step1-tpl"), buildTemplate("step1-tpl")); + await ctx.api.db.set( + buildTemplateKey("step1-tpl"), + buildTemplate("step1-tpl"), + ); const sub: SubscriberState = { campaignId: "cmp_done", @@ -1269,7 +1329,10 @@ describe("advanceDripSubscribers", () => { const ctx = makeCtx(); const campaign = buildCampaign("cmp_err"); await ctx.api.db.set(buildCampaignKey("cmp_err"), campaign); - await ctx.api.db.set(buildTemplateKey("step1-tpl"), buildTemplate("step1-tpl")); + await ctx.api.db.set( + buildTemplateKey("step1-tpl"), + buildTemplate("step1-tpl"), + ); const sub: SubscriberState = { campaignId: "cmp_err", @@ -1290,7 +1353,10 @@ describe("advanceDripSubscribers", () => { }; return cfg[k]; }); - (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + }); const sent = await advanceDripSubscribers(ctx); expect(sent).toBe(0); @@ -1360,9 +1426,16 @@ describe("user:register hook", () => { const ctx = makeCtx(); await ctx.api.db.set(buildTemplateKey("welcome"), SYSTEM_TEMPLATES[0]!); (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { - return k === "apiKey" ? "key" : k === "fromAddress" ? "f@e.com" : undefined; + return k === "apiKey" + ? "key" + : k === "fromAddress" + ? "f@e.com" + : undefined; + }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, }); - (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); const hook = plugin.definition.hooks?.["user:register"]; await expect(hook!(ctx, { email: "u@e.com" })).resolves.toBeUndefined(); @@ -1452,7 +1525,10 @@ describe("conversation:end hook", () => { }; return cfg[k]; }); - (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); + (ctx.api.makeRequest as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + }); const hook = plugin.definition.hooks?.["conversation:end"]; await expect( @@ -1464,4 +1540,3 @@ describe("conversation:end hook", () => { ); }); }); - diff --git a/packages/plugins/official/email-automation/src/index.ts b/packages/plugins/official/email-automation/src/index.ts index 0e7eb27..7003019 100644 --- a/packages/plugins/official/email-automation/src/index.ts +++ b/packages/plugins/official/email-automation/src/index.ts @@ -249,9 +249,11 @@ export async function generateEmailCopy( if (!response.ok) throw new Error(`AI service error: ${response.status}`); const data = (await response.json()) as Record; - const choices = data["choices"] as Array<{ - message: { content: string }; - }> | undefined; + const choices = data["choices"] as + | Array<{ + message: { content: string }; + }> + | undefined; if (choices?.[0]?.message?.content) return choices[0].message.content; if (typeof data["content"] === "string") return data["content"] as string; @@ -295,8 +297,7 @@ export async function advanceDripSubscribers( if (!template) continue; const subject = - step.subject ?? - interpolate(template.subject, { email: state.email }); + step.subject ?? interpolate(template.subject, { email: state.email }); const html = interpolate(template.body, { email: state.email }); try { @@ -422,15 +423,14 @@ export default createPlugin({ auth: true, description: "Send a transactional email", handler: async (req, res) => { - const { to, subject, html, text, templateSlug, vars } = - req.body as { - to?: string; - subject?: string; - html?: string; - text?: string; - templateSlug?: string; - vars?: Record; - }; + const { to, subject, html, text, templateSlug, vars } = req.body as { + to?: string; + subject?: string; + html?: string; + text?: string; + templateSlug?: string; + vars?: Record; + }; if (!to) { res.status(400).json({ error: "`to` is required" }); @@ -473,7 +473,9 @@ export default createPlugin({ { to, subject: finalSubject, html: finalHtml, text }, ); await api.db.set(buildSentKey(receipt.messageId), receipt); - res.status(200).json({ success: true, messageId: receipt.messageId }); + res + .status(200) + .json({ success: true, messageId: receipt.messageId }); } catch (err) { res.status(500).json({ error: (err as Error).message }); } @@ -585,9 +587,7 @@ export default createPlugin({ req.body as Partial; if (!name || !steps || steps.length === 0) { - res - .status(400) - .json({ error: "name and steps[] are required" }); + res.status(400).json({ error: "name and steps[] are required" }); return; } @@ -708,7 +708,9 @@ export default createPlugin({ completed: campaign.steps.length === 0, }; await api.db.set(subKey, state); - res.status(201).json({ subscribed: true, nextSendAt: state.nextSendAt }); + res + .status(201) + .json({ subscribed: true, nextSendAt: state.nextSendAt }); }, });