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 RecordH
", + }); + + const parsed = JSON.parse(capturedBody ?? "{}") as RecordH
", + }), + ).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 RecordH
" } }), + 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: RecordH
" } }), + 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: RecordThanks 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: + "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