diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 8d0e1f821..683f764a3 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -90553,6 +90553,7 @@ { "name": "type", "in": "query", + "required": true, "schema": { "type": "string", "enum": [ diff --git a/apps/api/package.json b/apps/api/package.json index 5c9670384..dad83459b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -61,6 +61,7 @@ "nodemailer": "^10.0.1", "pg": "8.23.0", "pino": "9.6.0", + "sendlit": "^0.5.0", "swagger-ui-express": "5.0.1", "zod": "3.25.76" }, diff --git a/apps/api/src/contact-tags.test.ts b/apps/api/src/contact-tags.test.ts index 180ffe92e..d55b949f1 100644 --- a/apps/api/src/contact-tags.test.ts +++ b/apps/api/src/contact-tags.test.ts @@ -5,6 +5,7 @@ import { dispatch } from "./dispatch.js"; import { createPgliteRuntime, freezeRuntimeClock, type Runtime } from "./runtime.js"; import { createSchool } from "./schools.js"; import { seedWorld, type SeededWorld } from "./seed.js"; +import { jsonResponse } from "./sendlit-test-support.js"; import { deleteSendLitTag, getSendLitTagReferences, @@ -57,7 +58,7 @@ function mockSendLit( } const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { status }); + jsonResponse(body, status); describe("SendLit tag client", () => { it("uses the right paths, methods and encoding", async () => { diff --git a/apps/api/src/contacts-paging.test.ts b/apps/api/src/contacts-paging.test.ts index 0ce2903b3..a3b5695ef 100644 --- a/apps/api/src/contacts-paging.test.ts +++ b/apps/api/src/contacts-paging.test.ts @@ -6,6 +6,7 @@ import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; import { createSchool } from "./schools.js"; import { seedWorld } from "./seed.js"; import { encryptIntegrationSecret } from "./utils/integration-secrets.js"; +import { jsonResponse } from "./sendlit-test-support.js"; describe("contact list paging", () => { it("asks SendLit for the page by its number, so no contact is skipped", async () => { @@ -49,7 +50,7 @@ describe("contact list paging", () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL) => { requested.push(new URL(String(input))); - return new Response(JSON.stringify({ items: [], total: 120 })); + return jsonResponse({ items: [], total: 120 }); }) as typeof fetch; try { diff --git a/apps/api/src/dispatch.ts b/apps/api/src/dispatch.ts index 762127c07..a874b3a3d 100644 --- a/apps/api/src/dispatch.ts +++ b/apps/api/src/dispatch.ts @@ -3084,8 +3084,16 @@ export async function dispatch( if (!context.permissions.has("contacts:read")) { return errorResponse(createPlatformError("forbidden")); } + const type = query.get("type"); + if (type !== "broadcast" && type !== "sequence") { + return errorResponse( + createPlatformError("validation_failed", { + safeDetails: { reason: "type must be broadcast or sequence" }, + }), + ); + } const result = await listSequences(deps.db, context.tenantId!, deps.clock, { - type: query.get("type") ?? undefined, + type, status: query.get("status") ?? undefined, search: query.get("search") ?? undefined, cursor: query.get("cursor") ?? undefined, diff --git a/apps/api/src/integration-provisioning.test.ts b/apps/api/src/integration-provisioning.test.ts index 31949d696..22d4c3f73 100644 --- a/apps/api/src/integration-provisioning.test.ts +++ b/apps/api/src/integration-provisioning.test.ts @@ -136,10 +136,16 @@ describe("CourseLit SendLit provisioning", () => { externalId: input.externalId, name: input.name, created: true, + deliverySource: { type: "organization" }, apiKey: "sl_live_key_999", }; }, - rotateKey: async () => ({ keyId: "key_1", key: "sl_live_rotated_key" }), + rotateKey: async () => ({ + name: "courselit", + keyId: "key_1", + keyPrefix: "sl_live_rotat", + key: "sl_live_rotated_key", + }), updateTeamQuota: async (teamId, quota) => { quotas.push({ teamId, monthlyLimit: quota.monthlyLimit }); }, @@ -150,7 +156,7 @@ describe("CourseLit SendLit provisioning", () => { clock, runtime.logger, sendLitOperations, - { server: "http://127.0.0.1:4101", provisioningApiKey: "sl_org_key" }, + { server: "http://127.0.0.1:4101", provisioningApiKey: "sl_org_live_test" }, ); expect(processed).toBe(true); // Its email goes out under the school's name, not the shared account's. @@ -216,16 +222,22 @@ describe("CourseLit SendLit provisioning", () => { externalId: input.externalId, name: input.name, created: true, + deliverySource: { type: "organization" }, // SendLit hands over the key only here, when it creates the team. apiKey: "sl_live_key_once", }; }, - rotateKey: async () => ({ keyId: "key_1", key: "sl_live_rotated_key" }), + rotateKey: async () => ({ + name: "courselit", + keyId: "key_1", + keyPrefix: "sl_live_rotat", + key: "sl_live_rotated_key", + }), updateTeamQuota: async () => { if (quotaFails) throw new Error("SendLit API error 403: organization_scope_required"); }, }; - const config = { server: "http://127.0.0.1:4101", provisioningApiKey: "sl_org_key" }; + const config = { server: "http://127.0.0.1:4101", provisioningApiKey: "sl_org_live_test" }; const integration = async () => ( await runtime.db @@ -353,9 +365,15 @@ describe("CourseLit SendLit provisioning", () => { externalId: created.value.id, name: "Test", created: true, + deliverySource: { type: "organization" }, apiKey: "sl_live_key", }), - rotateKey: async () => ({ keyId: "kid_1", key: "sl_live_key" }), + rotateKey: async () => ({ + name: "courselit", + keyId: "kid_1", + keyPrefix: "sl_live_key", + key: "sl_live_key", + }), listContacts: async () => ({ items: [], total: 0 }), createContact: async (_key, input) => { syncAttempts += 1; @@ -370,6 +388,7 @@ describe("CourseLit SendLit provisioning", () => { subscribed: true, tags: [], customFields: {}, + unsubscribeToken: "tok_1", createdAt: clock.now().toISOString(), updatedAt: clock.now().toISOString(), }; @@ -383,6 +402,7 @@ describe("CourseLit SendLit provisioning", () => { subscribed: input.subscribed ?? true, tags: input.tags ?? [], customFields: input.customFields ?? {}, + unsubscribeToken: "tok_1", createdAt: clock.now().toISOString(), updatedAt: clock.now().toISOString(), }; diff --git a/apps/api/src/integration-provisioning.ts b/apps/api/src/integration-provisioning.ts index 343d584cd..cb49bc96f 100644 --- a/apps/api/src/integration-provisioning.ts +++ b/apps/api/src/integration-provisioning.ts @@ -23,6 +23,8 @@ import { rotateSendLitTeamKey, updateSendLitTeamQuota, SendLitApiError, + type SendLitCustomFields, + type SendLitUpdateContactInput, type SendLitConfig, type SendLitContact, sendLitConfig, @@ -434,7 +436,7 @@ async function syncSendLitContactJob( schoolAccount.learnerRegisteredAt ?? schoolAccount.createdAt ).toISOString(); - const mergedCustomFields: Record = { + const mergedCustomFields: SendLitCustomFields = { ...(currentContact?.customFields ?? {}), [COURSELIT_PRODUCT_IDS_FIELD]: productIds, [COURSELIT_IS_COMMUNITY_MEMBER_FIELD]: isCommunityMember ? "true" : "false", @@ -443,11 +445,7 @@ async function syncSendLitContactJob( [COURSELIT_SIGNED_UP_FIELD]: signedUp, }; - const updateBody: { - name: string; - customFields: Record; - subscribed?: boolean; - } = { + const updateBody: SendLitUpdateContactInput = { name: schoolAccount.displayName, customFields: mergedCustomFields, }; @@ -732,7 +730,7 @@ async function failJob( error instanceof SendLitApiError && (error.status === 402 || error.status === 403 || - error.message.includes("plan_limit_exceeded")) + error.code === "plan_limit_exceeded") ) { message = "sendlit_quota_exceeded"; actionRequired = true; diff --git a/apps/api/src/learner-profile-update.test.ts b/apps/api/src/learner-profile-update.test.ts index 6e9ae20f3..9432c4225 100644 --- a/apps/api/src/learner-profile-update.test.ts +++ b/apps/api/src/learner-profile-update.test.ts @@ -24,6 +24,7 @@ function contact( subscribed: true, tags: [], customFields: {}, + unsubscribeToken: "tok_1", createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", ...input, diff --git a/apps/api/src/learner-profile.test.ts b/apps/api/src/learner-profile.test.ts index 1e31c2e69..353129cee 100644 --- a/apps/api/src/learner-profile.test.ts +++ b/apps/api/src/learner-profile.test.ts @@ -19,6 +19,7 @@ describe("learner SendLit profile mapping", () => { [LEARNER_AVATAR_URL_FIELD]: "https://media.example/avatar.webp", }, tags: ["learner"], + unsubscribeToken: "tok_1", createdAt: null, updatedAt: null, }); diff --git a/apps/api/src/learner-profile.ts b/apps/api/src/learner-profile.ts index 8cc3feca8..221b05369 100644 --- a/apps/api/src/learner-profile.ts +++ b/apps/api/src/learner-profile.ts @@ -6,6 +6,7 @@ import { SendLitApiError, type SendLitConfig, type SendLitContact, + type SendLitCustomFields, updateSendLitContact, } from "./sendlit-client.js"; import type { AppDb } from "./types.js"; @@ -102,10 +103,10 @@ export async function readLearnerProfile( } function profileCustomFields( - existing: Record, + existing: SendLitCustomFields, input: { bio: string; avatarMediaId: string | null; avatarUrl: string | null }, ) { - const customFields = { ...existing }; + const customFields: SendLitCustomFields = { ...existing }; if (input.bio.trim()) customFields[LEARNER_BIO_FIELD] = input.bio.trim(); else delete customFields[LEARNER_BIO_FIELD]; if (input.avatarMediaId) { diff --git a/apps/api/src/mail-automation.test.ts b/apps/api/src/mail-automation.test.ts index a296dadc6..ea35c8f50 100644 --- a/apps/api/src/mail-automation.test.ts +++ b/apps/api/src/mail-automation.test.ts @@ -343,10 +343,10 @@ describe("section drip emails", () => { const sent: Array<{ to: string; subject?: string; html?: string }> = []; const sendEmail = async ( _key: string, - input: { to: string; subject?: string; html?: string }, + input: { to: string; subject: string; html?: string }, ) => { sent.push(input); - return { txeId: `txe_${sent.length}` }; + return { txeId: `txe_${sent.length}`, status: "queued" as const }; }; const beforeUnlock = freezeRuntimeClock(new Date("2026-03-01T12:00:00.000Z")); @@ -412,17 +412,22 @@ describe("section drip emails", () => { let attempts = 0; let quotaLeft = false; const sent: string[] = []; - const sendEmail = async (_key: string, input: { to: string }) => { + const sendEmail = async ( + _key: string, + input: { to: string; subject: string }, + ) => { attempts += 1; if (!quotaLeft) { throw new SendLitApiError( - "SendLit request failed with HTTP 429: organization_team_quota_exhausted", + "SendLit API error 429: Team quota exhausted", 429, true, + "organization_team_quota_exhausted", + "organization_team_quota_exhausted", ); } sent.push(input.to); - return { txeId: "txe_1" }; + return { txeId: "txe_1", status: "queued" as const }; }; const warnings: unknown[] = []; const logger = { warn: (...args: unknown[]) => warnings.push(args), info: () => {} }; diff --git a/apps/api/src/marketing-quota.test.ts b/apps/api/src/marketing-quota.test.ts index 3a0559ffb..94eb4e626 100644 --- a/apps/api/src/marketing-quota.test.ts +++ b/apps/api/src/marketing-quota.test.ts @@ -9,7 +9,7 @@ import { seedWorld } from "./seed.js"; import { provisionSendLitTeam, updateSendLitTeamQuota } from "./sendlit-client.js"; import { planLimits } from "./plan-limits-config.js"; -const config = { server: "http://sendlit.test", provisioningApiKey: "sl_org_key" }; +const config = { server: "http://sendlit.test", provisioningApiKey: "sl_org_live_test" }; describe("marketingQuotaFor", () => { it("follows the plan limits", () => { @@ -149,7 +149,14 @@ function capture() { body: init?.body ? JSON.parse(String(init.body)) : {}, }); return new Response( - JSON.stringify({ teamId: "t1", externalId: "e1", name: "n", created: true, apiKey: "k" }), + JSON.stringify({ + teamId: "t1", + externalId: "e1", + name: "n", + created: true, + deliverySource: { type: "organization" }, + apiKey: "k", + }), { status: 200, headers: { "content-type": "application/json" } }, ); }) as typeof fetch; diff --git a/apps/api/src/sendlit-client.test.ts b/apps/api/src/sendlit-client.test.ts index c0cbc1794..20d173f7b 100644 --- a/apps/api/src/sendlit-client.test.ts +++ b/apps/api/src/sendlit-client.test.ts @@ -16,6 +16,7 @@ import { updateSendLitGeneralSettings, updateSendLitSequence, } from "./sendlit-client.js"; +import { jsonResponse } from "./sendlit-test-support.js"; describe("sendlit-client", () => { it("uses configured server and organization key in development", () => { @@ -67,7 +68,7 @@ describe("sendlit-client", () => { expect(String(input)).toBe("http://127.0.0.1:4101/provisioning/teams"); expect(init?.method).toBe("POST"); const headers = init?.headers as Record; - expect(headers.authorization).toBe("Bearer test-org-key"); + expect(headers.authorization).toBe("Bearer sl_org_live_test"); expect(headers["content-type"]).toBe("application/json"); const body = JSON.parse(init?.body as string); @@ -91,7 +92,7 @@ describe("sendlit-client", () => { { config: { server: "http://127.0.0.1:4101", - provisioningApiKey: "test-org-key", + provisioningApiKey: "sl_org_live_test", }, fetcher, }, @@ -226,19 +227,18 @@ describe("sendlit-client", () => { ); } if (url.includes("/templates") && init?.method === "GET") { - return new Response( - JSON.stringify({ - items: [ - { - templateId: "tpl_1", - title: "Default Template", - purpose: "marketing", - content: { content: [] }, - }, - ], - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); + // SendLit lists templates as a bare array. + return jsonResponse([ + { + templateId: "tpl_1", + title: "Default Template", + purpose: "marketing", + content: { style: {}, meta: {}, content: [] }, + requiredVariables: [], + createdAt: null, + updatedAt: null, + }, + ]); } throw new Error(`Unhandled request: ${url}`); }; @@ -294,19 +294,19 @@ describe("sendlit-client", () => { const body = String(input).endsWith("/events") ? { eventId: "evt_1", contactId: "cnt_1", duplicate: false, contactCreated: false } : { sent: ["me@example.com"], lastTestSentAt: "2026-03-01T00:00:00.000Z" }; - return new Response(JSON.stringify(body), { status: 200 }); + return jsonResponse(body); }; const config = { server: "http://sendlit.test", provisioningApiKey: null }; const test = await sendSendLitSequenceEmailTest( "sl_team", - "seq 1", + "seq_1", "eml_1", { to: ["me@example.com"] }, { config, fetcher }, ); expect(test.sent).toEqual(["me@example.com"]); - expect(calls[0]!.url).toBe("http://sendlit.test/sequences/seq%201/emails/eml_1/test"); + expect(calls[0]!.url).toBe("http://sendlit.test/sequences/seq_1/emails/eml_1/test"); expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ to: ["me@example.com"] }); expect((calls[0]!.init?.headers as Record)["x-sendlit-apikey"]).toBe( "sl_team", diff --git a/apps/api/src/sendlit-client.ts b/apps/api/src/sendlit-client.ts index 9346af922..c1d7dfd9b 100644 --- a/apps/api/src/sendlit-client.ts +++ b/apps/api/src/sendlit-client.ts @@ -1,9 +1,49 @@ /** - * CourseLit's server-side adapter for SendLit's supported REST API. Hand-written - * to maintain strict boundary separation: CourseLit never imports SendLit workspace - * contracts or internal database types. + * CourseLit's server-side adapter for SendLit's supported REST API, built on + * the official `sendlit` SDK. CourseLit never imports SendLit workspace + * contracts or internal database types; the SDK is the only boundary. */ +import { + type AddSequenceEmailInput, + type Contact, + type CreateContactInput, + type CreatedTeamKey, + type CreateSegmentInput, + type CreateSequenceInput, + type CreateTeamKeyInput, + type CreateTemplateInput, + type EmailTemplate, + type GeneralSettings, + type ListContactsQuery, + type ListSequencesQuery, + type ProvisionedTeamUsage, + type ProvisionTeamInput, + type ProvisionTeamResponse, + type RemoveTagResult, + type RenameTagInput, + type RenameTagResult, + type Segment, + type SendEmailInput, + type SendEmailResponse, + SendLit, + SendLitError, + type SendSequenceEmailTestInput, + type Sequence, + type SequenceEmailTestResult, + type SequenceStats, + type SystemTemplate, + type Tag, + type TagReferences, + type TrackEventInput, + type TrackEventResponse, + type UpdateContactInput, + type UpdateSegmentInput, + type UpdateSequenceEmailInput, + type UpdateSequenceInput, + type UpdateTemplateInput, +} from "sendlit"; + export type SendLitConfig = { server: string | null; provisioningApiKey: string | null; @@ -16,6 +56,8 @@ export class SendLitApiError extends Error { public readonly retryable = true, /** The service's own error text, safe to show to creators. */ public readonly detail?: string, + /** SendLit's machine-readable error code, e.g. `plan_limit_exceeded`. */ + public readonly code?: string, ) { super(message); this.name = "SendLitApiError"; @@ -31,7 +73,7 @@ export function isSendLitQuotaExhausted(error: unknown): boolean { return ( error instanceof SendLitApiError && error.status === 429 && - /quota_exhausted/.test(`${error.message} ${error.detail ?? ""}`) + /quota_exhausted/.test(`${error.code ?? ""} ${error.detail ?? ""}`) ); } @@ -49,339 +91,141 @@ export type FetchLike = ( init?: RequestInit, ) => Promise; -export type SendLitProvisionTeamInput = { - externalId: string; - name: string; - sender?: { - fromName?: string; - replyTo?: string; - }; - mailingAddress?: string; - delivery?: { - source: "organization" | "custom"; - espId?: string; - }; - /** - * SendLit's field names (`schemas/teams.ts` in SendLit's API contract). On - * provisioning SendLit only accepts positive numbers; use - * `updateSendLitTeamQuota` to set 0. `null` means unlimited. - */ - quota?: { - monthlyLimit?: number | null; - dailyLimit?: number | null; - }; -}; - -export type SendLitProvisionTeamResult = { - teamId: string; - externalId: string; - name: string; - deliverySource?: { - type: string; - espId?: string; - }; - created: boolean; - apiKey: string | null; -}; - -export type SendLitContact = { - contactId: string; - email: string; - name: string | null; - subscribed: boolean; - customFields: Record; - tags: string[]; - unsubscribeToken?: string; - createdAt: string | null; - updatedAt: string | null; -}; - -export type SendLitFilterCondition = - | { name: "email"; condition: "is" | "contains" | "not_contains"; value: string; valueLabel?: string } - | { name: "tag"; condition: "is" | "is_not"; value: string; valueLabel?: string } - | { name: "subscription"; condition: "is"; value: "subscribed" | "unsubscribed"; valueLabel?: string } - | { name: "signedUp"; condition: "before" | "after" | "on"; value: string; valueLabel?: string } - | { - name: "customField"; - key: string; - condition: - | "is" - | "is_not" - | "contains" - | "not_contains" - | "has" - | "not_has" - | "before" - | "after" - | "on" - | "exists" - | "not_exists"; - value?: string; - valueLabel?: string; - }; - -export type SendLitSegmentFilter = { - aggregator: "and" | "or"; - filters: SendLitFilterCondition[]; -}; - -export type SendLitSegment = { - segmentId: string; - name: string; - filter: SendLitSegmentFilter; - createdAt: string | null; - updatedAt: string | null; -}; - -export type SendLitTemplateContent = { - style?: Record; - meta?: Record; - content: unknown; -}; - -export type SendLitTemplate = { - templateId: string; - title: string; - purpose: "marketing" | "transactional"; - content: SendLitTemplateContent; - createdAt: string | null; - updatedAt: string | null; -}; - -export type SendLitSystemTemplate = { - templateId: string; - title: string; - purpose: "marketing" | "transactional"; - content: SendLitTemplateContent; -}; - -export type SendLitSequenceEmail = { - emailId: string; - id?: string; - subject: string; - content: unknown; - delayInMillis: number; - published: boolean; - templateId?: string; - createdAt?: string | null; - updatedAt?: string | null; -}; - -export type SendLitSequence = { - sequenceId: string; - id?: string; - title?: string; - type: "broadcast" | "sequence"; - status?: string; - templateId?: string; - emails?: SendLitSequenceEmail[]; - triggerType?: string | null; - triggerData?: string | null; - triggerFilter?: unknown; - filter?: unknown; - excludeFilter?: unknown; - emailsOrder?: string[]; - /** Whether emails link to a hosted copy of themselves. */ - viewInBrowser?: boolean; - entrantsCount?: number; - createdAt?: string | null; - updatedAt?: string | null; -}; - -export function normalizeSendLitSequenceEmail(raw: any): SendLitSequenceEmail { - if (!raw || typeof raw !== "object") return raw; - const emailId = raw.emailId || raw.id || ""; - return { - ...raw, - emailId, - id: emailId, - }; -} - -export function normalizeSendLitSequence(raw: any): SendLitSequence { - if (!raw || typeof raw !== "object") return raw; - const sequenceId = raw.sequenceId || raw.id || ""; - const emails = Array.isArray(raw.emails) - ? raw.emails.map(normalizeSendLitSequenceEmail) - : []; - return { - ...raw, - sequenceId, - id: sequenceId, - emails, - }; -} +export type SendLitProvisionTeamInput = ProvisionTeamInput; +export type SendLitCustomFields = NonNullable; +export type SendLitUpdateContactInput = UpdateContactInput; +export type SendLitListSequencesQuery = ListSequencesQuery; +export type SendLitCreateSequenceInput = CreateSequenceInput; +export type SendLitUpdateSequenceInput = UpdateSequenceInput; +export type SendLitUpdateSequenceEmailInput = UpdateSequenceEmailInput; +export type SendLitProvisionTeamResult = ProvisionTeamResponse; +export type SendLitContact = Contact; +export type SendLitSegmentFilter = CreateSegmentInput["filter"]; +export type SendLitFilterCondition = SendLitSegmentFilter["filters"][number]; +export type SendLitSegment = Segment; +export type SendLitTemplateContent = CreateTemplateInput["content"]; +export type SendLitTemplate = EmailTemplate; +export type SendLitSystemTemplate = SystemTemplate; +export type SendLitSequence = Sequence; +export type SendLitSequenceEmail = Sequence["emails"][number]; +export type SendLitGeneralSettings = GeneralSettings; +export type SendLitSequenceStats = SequenceStats; +export type SendLitEmailTestResult = SequenceEmailTestResult; +export type SendLitTrackEventInput = TrackEventInput; +export type SendLitTrackEventResult = TrackEventResponse; +/** A team's marketing email this month, as SendLit counts it. */ +export type SendLitMonthUsage = ProvisionedTeamUsage["month"]; +export type SendLitTagSummary = Tag; +export type SendLitTagReferences = TagReferences; +export type SendLitRenameTagResult = RenameTagResult; +export type SendLitDeleteTagResult = RemoveTagResult; -function normalizeSequenceEmailResponse( - raw: any, +/** + * `addEmail` and `updateEmail` answer with the whole sequence; pick the + * email out of it. Without an explicit id, the newest email is the one just + * added. + */ +function emailFromSequence( + sequence: Sequence, expectedEmailId?: string, ): SendLitSequenceEmail { - if (raw && typeof raw === "object" && Array.isArray(raw.emails)) { - const sequence = normalizeSendLitSequence(raw); - const emailId = - expectedEmailId || - sequence.emailsOrder?.[sequence.emailsOrder.length - 1]; - const email = sequence.emails?.find((item) => item.emailId === emailId); - if (email) return email; - throw new Error("SendLit did not include the email in its sequence response."); - } - - // Keep compatibility with SendLit responses that return the email directly. - return normalizeSendLitSequenceEmail(raw); -} - -export function normalizeSendLitTemplate(raw: any): SendLitTemplate { - if (!raw || typeof raw !== "object") return raw; - const templateId = raw.templateId || raw.id || ""; - const title = raw.title || raw.name || "Untitled Template"; - return { - ...raw, - templateId, - id: templateId, - title, - name: title, - purpose: raw.purpose || "marketing", - content: raw.content ?? { content: { html: "" } }, - createdAt: raw.createdAt ?? null, - updatedAt: raw.updatedAt ?? null, - }; + const emailId = + expectedEmailId || sequence.emailsOrder[sequence.emailsOrder.length - 1]; + const email = sequence.emails.find((item) => item.emailId === emailId); + if (email) return email; + throw new Error("SendLit did not include the email in its sequence response."); } -export type SendLitGeneralSettings = { - mailingAddress: string | null; - updatedAt?: string | null; -}; - -export type SendLitSequenceStats = { - sent: number; - openRate: number; - clickThroughRate: number; - subscribersCount: number; - bounceRate?: number; - complaintRate?: number; -}; - -export type SendLitEmailTestResult = { - sent: string[]; - skipped?: unknown; - lastTestSentAt: string | null; -}; - -export type SendLitTrackEventInput = { - email?: string; - contactId?: string; - eventName: string; - properties?: Record; - occurredAt?: string; - idempotencyKey?: string; - createContact?: boolean; - contact?: { name?: string; customFields?: Record }; -}; - -export type SendLitTrackEventResult = { - eventId: string; - contactId: string; - duplicate: boolean; - contactCreated: boolean; -}; +// --------------------------------------------------------------------------- +// Transport: the official `sendlit` SDK +// --------------------------------------------------------------------------- -export type SendLitOverview = { - totalSubscribers?: number; - activeSubscribers?: number; - contacts?: { total: number; subscribed: number; unsubscribed: number }; - deliveries?: Record; - rangeDays?: number; -}; +/** Each attempt is aborted after this long. */ +const REQUEST_TIMEOUT_MS = 15_000; -function errorMessage(status: number, body: string): string { - const detail = body.replace(/\s+/g, " ").trim().slice(0, 300); - return detail ? `SendLit API error ${status}: ${detail}` : `SendLit API error ${status}`; -} +type SendLitAuth = { teamApiKey?: string; provisioningApiKey?: string }; +type SendLitClientOptions = { config?: SendLitConfig; fetcher?: FetchLike }; -function errorDetail(body: string): string | undefined { - try { - const parsed = JSON.parse(body) as { error?: unknown; message?: unknown }; - const detail = typeof parsed.error === "string" ? parsed.error : parsed.message; - return typeof detail === "string" && detail.trim() ? detail.trim().slice(0, 300) : undefined; - } catch { - return undefined; +/** + * The one place the SDK's `SendLitError` becomes a `SendLitApiError`. + * `detail` is the response's own `error` text (or its `message`), and `code` + * is SendLit's machine-readable error code. + */ +function toSendLitApiError(error: unknown): unknown { + if (!(error instanceof SendLitError)) return error; + if (error.status === 0) { + // No response: a timeout, an unreachable server, or a rejected argument. + if (error.code === "timeout") + return new SendLitApiError("SendLit request timed out"); + if (error.code === "connection_error") + return new SendLitApiError("SendLit is unreachable"); + return new SendLitApiError(error.message, undefined, false, undefined, error.code); } + const body = + error.body && typeof error.body === "object" + ? (error.body as { error?: unknown; message?: unknown }) + : {}; + const detailText = typeof body.error === "string" ? body.error : body.message; + const detail = + typeof detailText === "string" && detailText.trim() + ? detailText.trim().slice(0, 300) + : undefined; + return new SendLitApiError( + `SendLit API error ${error.status}: ${error.message}`.slice(0, 340), + error.status, + error.status >= 500 || error.status === 429, + detail, + error.code, + ); } -async function requestJson( +/** + * Runs `call` against a fresh SDK client. The SDK's automatic retries are off + * because CourseLit's outbox already retries, the timeout matches the old + * client, and the injected `fetcher` is the SDK's `fetch`. + */ +async function withSendLit( config: SendLitConfig, - path: string, - options: { - method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; - teamApiKey?: string; - provisioningApiKey?: string; - body?: unknown; - }, - fetcher: FetchLike = fetch, + auth: SendLitAuth, + fetcher: FetchLike | undefined, + call: (sendlit: SendLit) => Promise, ): Promise { if (!config.server) { throw new SendLitApiError("SENDLIT_SERVER is not configured", undefined, false); } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); try { - let response: Response; - try { - const authHeaders: Record = {}; - if (options.teamApiKey) { - authHeaders["x-sendlit-apikey"] = options.teamApiKey; - } else if (options.provisioningApiKey) { - authHeaders.authorization = `Bearer ${options.provisioningApiKey}`; - } - - response = await fetcher(`${config.server}${path}`, { - method: options.method ?? "GET", - headers: { - accept: "application/json", - ...(options.body === undefined ? {} : { "content-type": "application/json" }), - ...authHeaders, - }, - body: options.body === undefined ? undefined : JSON.stringify(options.body), - signal: controller.signal, - }); - } catch (error) { - const message = - error instanceof Error && error.name === "AbortError" - ? "SendLit request timed out" - : "SendLit is unreachable"; - throw new SendLitApiError(message); - } - - if (response.status === 204) { - return undefined as T; - } - - if (!response.ok) { - const text = await response.text(); - const retryable = response.status >= 500 || response.status === 429; - throw new SendLitApiError( - errorMessage(response.status, text), - response.status, - retryable, - errorDetail(text), - ); - } - - return (await response.json()) as T; - } finally { - clearTimeout(timeout); + const sendlit = new SendLit({ + endpoint: config.server, + apiKey: auth.teamApiKey ?? auth.provisioningApiKey, + // Never pick this up from the process environment. + teamId: "", + maxRetries: 0, + timeout: REQUEST_TIMEOUT_MS, + fetch: fetcher as typeof fetch | undefined, + }); + return await call(sendlit); + } catch (error) { + throw toSendLitApiError(error); } } -// --------------------------------------------------------------------------- -// Provisioning operations (Organization API key scoped) -// --------------------------------------------------------------------------- +function team( + teamApiKey: string, + options: SendLitClientOptions, + call: (sendlit: SendLit) => Promise, +): Promise { + return withSendLit( + options.config ?? sendLitConfig(), + { teamApiKey }, + options.fetcher, + call, + ); +} -export async function provisionSendLitTeam( - input: SendLitProvisionTeamInput, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, -): Promise { +function provisioning( + options: SendLitClientOptions, + call: (sendlit: SendLit) => Promise, +): Promise { const config = options.config ?? sendLitConfig(); if (!config.provisioningApiKey) { throw new SendLitApiError( @@ -390,18 +234,25 @@ export async function provisionSendLitTeam( false, ); } - return requestJson( + return withSendLit( config, - "/provisioning/teams", - { - method: "POST", - provisioningApiKey: config.provisioningApiKey, - body: input, - }, + { provisioningApiKey: config.provisioningApiKey }, options.fetcher, + call, ); } +// --------------------------------------------------------------------------- +// Provisioning operations (Organization API key scoped) +// --------------------------------------------------------------------------- + +export async function provisionSendLitTeam( + input: SendLitProvisionTeamInput, + options: SendLitClientOptions = {}, +): Promise { + return provisioning(options, (s) => s.provisioning.provisionTeam(input)); +} + /** * Sets a provisioned team's sending quota (`PATCH /provisioning/teams/:id`). * Unlike provisioning, this accepts 0, which stops all sending from the team. @@ -409,125 +260,25 @@ export async function provisionSendLitTeam( export async function updateSendLitTeamQuota( teamId: string, quota: { monthlyLimit: number | null; dailyLimit?: number | null }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - if (!config.provisioningApiKey) { - throw new SendLitApiError( - "SENDLIT_ORGANIZATION_API_KEY is not configured", - undefined, - false, - ); - } - await requestJson( - config, - `/provisioning/teams/${encodeURIComponent(teamId)}`, - { - method: "PATCH", - provisioningApiKey: config.provisioningApiKey, - body: { quota }, - }, - options.fetcher, - ); + await provisioning(options, (s) => s.provisioning.updateTeam(teamId, { quota })); } -/** A team's marketing email this month, as SendLit counts it. */ -export type SendLitMonthUsage = { limit: number | null; accepted: number; reserved: number; resetsAt: string }; - export async function getSendLitTeamUsage( teamId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - if (!config.provisioningApiKey) { - throw new SendLitApiError( - "SENDLIT_ORGANIZATION_API_KEY is not configured", - undefined, - false, - ); - } - const usage = await requestJson<{ month: SendLitMonthUsage }>( - config, - `/provisioning/teams/${encodeURIComponent(teamId)}/usage`, - { method: "GET", provisioningApiKey: config.provisioningApiKey }, - options.fetcher, - ); + const usage = await provisioning(options, (s) => s.provisioning.getTeamUsage(teamId)); return usage.month; } export async function rotateSendLitTeamKey( teamId: string, - input: { name: string }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, -): Promise<{ keyId: string; key: string }> { - const config = options.config ?? sendLitConfig(); - if (!config.provisioningApiKey) { - throw new SendLitApiError( - "SENDLIT_ORGANIZATION_API_KEY is not configured", - undefined, - false, - ); - } - return requestJson<{ keyId: string; key: string }>( - config, - `/provisioning/teams/${encodeURIComponent(teamId)}/keys`, - { - method: "POST", - provisioningApiKey: config.provisioningApiKey, - body: input, - }, - options.fetcher, - ); -} - -export async function getSendLitProvisionedTeam( - teamId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, -): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/provisioning/teams/${encodeURIComponent(teamId)}`, - { - method: "GET", - provisioningApiKey: config.provisioningApiKey ?? undefined, - }, - options.fetcher, - ); -} - -export async function updateSendLitProvisionedTeam( - teamId: string, - input: { name?: string; sender?: unknown; mailingAddress?: string }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, -): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/provisioning/teams/${encodeURIComponent(teamId)}`, - { - method: "PATCH", - provisioningApiKey: config.provisioningApiKey ?? undefined, - body: input, - }, - options.fetcher, - ); -} - -export async function deleteSendLitProvisionedTeam( - teamId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, -): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/provisioning/teams/${encodeURIComponent(teamId)}`, - { - method: "DELETE", - provisioningApiKey: config.provisioningApiKey ?? undefined, - }, - options.fetcher, - ); + input: CreateTeamKeyInput, + options: SendLitClientOptions = {}, +): Promise { + return provisioning(options, (s) => s.provisioning.createTeamKey(teamId, input)); } // --------------------------------------------------------------------------- @@ -536,29 +287,17 @@ export async function deleteSendLitProvisionedTeam( export async function getSendLitGeneralSettings( teamApiKey: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - "/settings/general", - { method: "GET", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.settings.general.get()); } export async function updateSendLitGeneralSettings( teamApiKey: string, input: { mailingAddress: string }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - "/settings/general", - { method: "PUT", teamApiKey, body: input }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.settings.general.update(input)); } // --------------------------------------------------------------------------- @@ -567,256 +306,118 @@ export async function updateSendLitGeneralSettings( export async function listSendLitContacts( teamApiKey: string, - query: { - offset?: number; - rowsPerPage?: number; - q?: string; - segmentId?: string; - filter?: string; - tag?: string; - } = {}, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + query: ListContactsQuery = {}, + options: SendLitClientOptions = {}, ): Promise<{ items: SendLitContact[]; total: number }> { - const config = options.config ?? sendLitConfig(); - const search = new URLSearchParams(); - if (query.offset !== undefined) search.set("offset", String(query.offset)); - if (query.rowsPerPage !== undefined) search.set("rowsPerPage", String(query.rowsPerPage)); - if (query.q) search.set("q", query.q); - if (query.segmentId) search.set("segmentId", query.segmentId); - if (query.filter) search.set("filter", query.filter); - if (query.tag) search.set("tag", query.tag); - - const qs = search.toString(); - return requestJson<{ items: SendLitContact[]; total: number }>( - config, - `/contacts${qs ? `?${qs}` : ""}`, - { method: "GET", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.contacts.list(query)); } export async function createSendLitContact( teamApiKey: string, - input: { - email: string; - name?: string; - tags?: string[]; - customFields?: Record; - }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: CreateContactInput, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - "/contacts", - { method: "POST", teamApiKey, body: input }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.contacts.create(input)); } export async function getSendLitContact( teamApiKey: string, contactId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/contacts/${encodeURIComponent(contactId)}`, - { method: "GET", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.contacts.get(contactId)); } export async function updateSendLitContact( teamApiKey: string, contactId: string, - input: { - email?: string; - name?: string; - subscribed?: boolean; - tags?: string[]; - customFields?: Record; - }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: UpdateContactInput, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/contacts/${encodeURIComponent(contactId)}`, - { method: "PATCH", teamApiKey, body: input }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.contacts.update(contactId, input)); } export async function deleteSendLitContact( teamApiKey: string, contactId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/contacts/${encodeURIComponent(contactId)}`, - { method: "DELETE", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.contacts.remove(contactId)); } export async function addSendLitContactTag( teamApiKey: string, contactId: string, tag: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/contacts/${encodeURIComponent(contactId)}/tags/${encodeURIComponent(tag)}`, - { method: "POST", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.contacts.addTag(contactId, tag)); } export async function removeSendLitContactTag( teamApiKey: string, contactId: string, tag: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/contacts/${encodeURIComponent(contactId)}/tags/${encodeURIComponent(tag)}`, - { method: "DELETE", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.contacts.removeTag(contactId, tag)); } // --------------------------------------------------------------------------- // Segments operations (Team API key scoped) // --------------------------------------------------------------------------- +/** SendLit lists segments as a bare array; callers read `{ items }`. */ export async function listSendLitSegments( teamApiKey: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise<{ items: SendLitSegment[] }> { - const config = options.config ?? sendLitConfig(); - // SendLit answers with a bare array of segments. - const items = await requestJson( - config, - "/segments", - { method: "GET", teamApiKey }, - options.fetcher, - ); + const items = await team(teamApiKey, options, (s) => s.segments.list()); return { items }; } export async function createSendLitSegment( teamApiKey: string, - input: { name: string; filter: SendLitSegmentFilter }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: CreateSegmentInput, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - "/segments", - { method: "POST", teamApiKey, body: input }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.segments.create(input)); } export async function getSendLitSegment( teamApiKey: string, segmentId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/segments/${encodeURIComponent(segmentId)}`, - { method: "GET", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.segments.get(segmentId)); } export async function updateSendLitSegment( teamApiKey: string, segmentId: string, - input: { name?: string; filter?: SendLitSegmentFilter }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: UpdateSegmentInput, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/segments/${encodeURIComponent(segmentId)}`, - { method: "PATCH", teamApiKey, body: input }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.segments.update(segmentId, input)); } export async function deleteSendLitSegment( teamApiKey: string, segmentId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/segments/${encodeURIComponent(segmentId)}`, - { method: "DELETE", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.segments.remove(segmentId)); } // --------------------------------------------------------------------------- // Tag operations (Team API key scoped). Tags live on SendLit contacts. // --------------------------------------------------------------------------- -export type SendLitTagSummary = { tag: string; contactCount: number }; - -export type SendLitTagReferences = { - tag: string; - contactCount: number; - segments: { segmentId: string; name: string }[]; - sequences: { - sequenceId: string; - title: string; - type: "broadcast" | "sequence"; - status: string; - usages: ("filter" | "exclude_filter" | "trigger" | "email_action")[]; - }[]; - signupForms: { formId: string; name: string }[]; -}; - -export type SendLitRenameTagResult = { - tag: string; - previousTag: string; - contactsUpdated: number; - segmentsUpdated: number; - sequencesUpdated: number; - signupFormsUpdated: number; -}; - -export type SendLitDeleteTagResult = { - tag: string; - contactsUpdated: number; - references: SendLitTagReferences; -}; - -type SendLitClientOptions = { config?: SendLitConfig; fetcher?: FetchLike }; - export async function listSendLitTags( teamApiKey: string, options: SendLitClientOptions = {}, ): Promise<{ items: SendLitTagSummary[] }> { - const config = options.config ?? sendLitConfig(); - return requestJson<{ items: SendLitTagSummary[] }>( - config, - "/tags", - { method: "GET", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.tags.list()); } export async function getSendLitTagReferences( @@ -824,28 +425,16 @@ export async function getSendLitTagReferences( tag: string, options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/tags/${encodeURIComponent(tag)}/references`, - { method: "GET", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.tags.references(tag)); } export async function renameSendLitTag( teamApiKey: string, tag: string, - input: { name: string }, + input: RenameTagInput, options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/tags/${encodeURIComponent(tag)}`, - { method: "PATCH", teamApiKey, body: input }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.tags.rename(tag, input)); } export async function deleteSendLitTag( @@ -853,153 +442,69 @@ export async function deleteSendLitTag( tag: string, options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/tags/${encodeURIComponent(tag)}`, - { method: "DELETE", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.tags.remove(tag)); } // --------------------------------------------------------------------------- // Templates operations (Team API key scoped) // --------------------------------------------------------------------------- +/** SendLit lists templates as a bare array; callers read `{ items, total }`. */ export async function listSendLitTemplates( teamApiKey: string, query: { purpose?: "marketing" | "transactional" } = {}, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise<{ items: SendLitTemplate[]; total: number }> { - const config = options.config ?? sendLitConfig(); - const search = new URLSearchParams(); - if (query.purpose) search.set("purpose", query.purpose); - const qs = search.toString(); - const raw = await requestJson( - config, - `/templates${qs ? `?${qs}` : ""}`, - { method: "GET", teamApiKey }, - options.fetcher, - ); - const rawList = Array.isArray(raw) - ? raw - : Array.isArray(raw?.items) - ? raw.items - : Array.isArray(raw?.templates) - ? raw.templates - : Array.isArray(raw?.data) - ? raw.data - : []; - const normalizedItems = rawList.map(normalizeSendLitTemplate); - const total = typeof raw?.total === "number" ? raw.total : normalizedItems.length; - return { items: normalizedItems, total }; + const items = await team(teamApiKey, options, (s) => s.templates.list(query)); + return { items, total: items.length }; } export async function createSendLitTemplate( teamApiKey: string, - input: { - title?: string; - name?: string; - purpose?: "marketing" | "transactional"; - content: SendLitTemplateContent; - }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: CreateTemplateInput, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const title = input.title ?? input.name ?? "Untitled Template"; - const raw = await requestJson( - config, - "/templates", - { method: "POST", teamApiKey, body: { ...input, title, name: title } }, - options.fetcher, - ); - return normalizeSendLitTemplate(raw); + return team(teamApiKey, options, (s) => s.templates.create(input)); } export async function getSendLitTemplate( teamApiKey: string, templateId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - `/templates/${encodeURIComponent(templateId)}`, - { method: "GET", teamApiKey }, - options.fetcher, - ); - return normalizeSendLitTemplate(raw); + return team(teamApiKey, options, (s) => s.templates.get(templateId)); } export async function updateSendLitTemplate( teamApiKey: string, templateId: string, - input: { - title?: string; - name?: string; - purpose?: "marketing" | "transactional"; - content?: SendLitTemplateContent; - }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: UpdateTemplateInput, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - `/templates/${encodeURIComponent(templateId)}`, - { method: "PATCH", teamApiKey, body: input }, - options.fetcher, - ); - return normalizeSendLitTemplate(raw); + return team(teamApiKey, options, (s) => s.templates.update(templateId, input)); } export async function duplicateSendLitTemplate( teamApiKey: string, templateId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - `/templates/${encodeURIComponent(templateId)}/duplicate`, - { method: "POST", teamApiKey }, - options.fetcher, - ); - return normalizeSendLitTemplate(raw); + return team(teamApiKey, options, (s) => s.templates.duplicate(templateId)); } export async function deleteSendLitTemplate( teamApiKey: string, templateId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/templates/${encodeURIComponent(templateId)}`, - { method: "DELETE", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.templates.remove(templateId)); } export async function listSendLitSystemTemplates( teamApiKey: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise<{ items: SendLitSystemTemplate[] }> { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - "/system-templates", - { method: "GET", teamApiKey }, - options.fetcher, - ); - const rawList = Array.isArray(raw) - ? raw - : Array.isArray(raw?.items) - ? raw.items - : Array.isArray(raw?.data) - ? raw.data - : []; - return { items: rawList.map(normalizeSendLitTemplate) }; + return team(teamApiKey, options, (s) => s.templates.listSystem()); } // --------------------------------------------------------------------------- @@ -1008,68 +513,35 @@ export async function listSendLitSystemTemplates( export async function listSendLitSequences( teamApiKey: string, - query: { - type?: "broadcast" | "sequence"; - offset?: number; - itemsPerPage?: number; - } = {}, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + query: ListSequencesQuery, + options: SendLitClientOptions = {}, ): Promise<{ items: SendLitSequence[]; total: number }> { - const config = options.config ?? sendLitConfig(); - const search = new URLSearchParams(); - if (query.type) search.set("type", query.type); - if (query.offset !== undefined) search.set("offset", String(query.offset)); - if (query.itemsPerPage !== undefined) search.set("itemsPerPage", String(query.itemsPerPage)); - const qs = search.toString(); - const raw = await requestJson( - config, - `/sequences${qs ? `?${qs}` : ""}`, - { method: "GET", teamApiKey }, - options.fetcher, - ); - const rawList: any[] = Array.isArray(raw) - ? raw - : Array.isArray(raw?.items) - ? raw.items - : Array.isArray(raw?.sequences) - ? raw.sequences - : Array.isArray(raw?.data) - ? raw.data - : []; - const normalizedItems = rawList.map(normalizeSendLitSequence); - const total = typeof raw?.total === "number" ? raw.total : normalizedItems.length; - return { items: normalizedItems, total }; + return team(teamApiKey, options, (s) => s.sequences.list(query)); } +/** + * SendLit's create call takes no title, so a given title is set with a + * follow-up update; if that fails the sequence is still returned with it. + */ export async function createSendLitSequence( teamApiKey: string, - input: { - type: "broadcast" | "sequence"; - title?: string; - templateId?: string; - deliverySource?: unknown; - }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: CreateSequenceInput & { title?: string }, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - "/sequences", - { method: "POST", teamApiKey, body: input }, - options.fetcher, - ); - let created = normalizeSendLitSequence(raw); - if (input.title && input.title.trim() && created.title !== input.title.trim()) { + const { title, ...body } = input; + let created = await team(teamApiKey, options, (s) => s.sequences.create(body)); + const wanted = title?.trim(); + if (wanted && created.title !== wanted) { try { const patched = await updateSendLitSequence( teamApiKey, created.sequenceId, - { title: input.title.trim() }, + { title: wanted }, options, ); - created = { ...created, ...patched, title: input.title.trim() }; + created = { ...created, ...patched, title: wanted }; } catch { - created.title = input.title.trim(); + created = { ...created, title: wanted }; } } return created; @@ -1078,88 +550,48 @@ export async function createSendLitSequence( export async function getSendLitSequence( teamApiKey: string, sequenceId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}`, - { method: "GET", teamApiKey }, - options.fetcher, - ); - return normalizeSendLitSequence(raw); + return team(teamApiKey, options, (s) => s.sequences.get(sequenceId)); } export async function updateSendLitSequence( teamApiKey: string, sequenceId: string, - input: { - title?: string; - triggerType?: string | null; - triggerData?: string | null; - triggerFilter?: unknown; - filter?: unknown; - excludeFilter?: unknown; - emailsOrder?: string[]; - viewInBrowser?: boolean; - }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: UpdateSequenceInput, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}`, - { method: "PATCH", teamApiKey, body: input }, - options.fetcher, - ); - return normalizeSendLitSequence(raw); + return team(teamApiKey, options, (s) => s.sequences.update(sequenceId, input)); } export async function deleteSendLitSequence( teamApiKey: string, sequenceId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}`, - { method: "DELETE", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.sequences.remove(sequenceId)); } export async function addSendLitSequenceEmail( teamApiKey: string, sequenceId: string, - input: { - subject: string; - content?: unknown; - delayInMillis?: number; - delayHours?: number; - templateId: string; - }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: AddSequenceEmailInput & + Pick & { + delayHours?: number; + }, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}/emails`, - { method: "POST", teamApiKey, body: { templateId: input.templateId } }, - options.fetcher, + const sequence = await team(teamApiKey, options, (s) => + s.sequences.addEmail(sequenceId, { templateId: input.templateId }), ); - const created = normalizeSequenceEmailResponse(raw); + const created = emailFromSequence(sequence); const delayInMillis = input.delayInMillis !== undefined ? input.delayInMillis : input.delayHours !== undefined ? input.delayHours * 3600 * 1000 : undefined; - const update: { - subject?: string; - content?: unknown; - delayInMillis?: number; - } = {}; + const update: UpdateSequenceEmailInput = {}; if (input.subject !== created.subject) update.subject = input.subject; if (input.content !== undefined) update.content = input.content; if (delayInMillis !== undefined && delayInMillis !== created.delayInMillis) { @@ -1180,81 +612,46 @@ export async function updateSendLitSequenceEmail( teamApiKey: string, sequenceId: string, emailId: string, - input: { - subject?: string; - content?: unknown; - delayInMillis?: number; - published?: boolean; - }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: UpdateSequenceEmailInput, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}/emails/${encodeURIComponent(emailId)}`, - { method: "PATCH", teamApiKey, body: input }, - options.fetcher, + const sequence = await team(teamApiKey, options, (s) => + s.sequences.updateEmail(sequenceId, emailId, input), ); - return normalizeSequenceEmailResponse(raw, emailId); + return emailFromSequence(sequence, emailId); } export async function deleteSendLitSequenceEmail( teamApiKey: string, sequenceId: string, emailId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}/emails/${encodeURIComponent(emailId)}`, - { method: "DELETE", teamApiKey }, - options.fetcher, - ); + await team(teamApiKey, options, (s) => s.sequences.removeEmail(sequenceId, emailId)); } export async function startSendLitSequence( teamApiKey: string, sequenceId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}/start`, - { method: "POST", teamApiKey }, - options.fetcher, - ); - return normalizeSendLitSequence(raw); + return team(teamApiKey, options, (s) => s.sequences.start(sequenceId)); } export async function pauseSendLitSequence( teamApiKey: string, sequenceId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const raw = await requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}/pause`, - { method: "POST", teamApiKey }, - options.fetcher, - ); - return normalizeSendLitSequence(raw); + return team(teamApiKey, options, (s) => s.sequences.pause(sequenceId)); } export async function getSendLitSequenceStats( teamApiKey: string, sequenceId: string, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}/stats`, - { method: "GET", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.sequences.stats(sequenceId)); } /** The mailing service reports a sequence's recipients as contact ids only. */ @@ -1262,40 +659,20 @@ export async function listSendLitSequenceSubscribers( teamApiKey: string, sequenceId: string, query: { page?: number; limit?: number } = {}, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - const search = new URLSearchParams(); - if (query.page !== undefined) search.set("page", String(query.page)); - if (query.limit !== undefined) search.set("limit", String(query.limit)); - const qs = search.toString(); - const raw = await requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}/subscribers${qs ? `?${qs}` : ""}`, - { method: "GET", teamApiKey }, - options.fetcher, - ); - const list = Array.isArray(raw) - ? raw - : Array.isArray((raw as { items?: unknown })?.items) - ? (raw as { items: unknown[] }).items - : []; - return list.filter((id): id is string => typeof id === "string" && id !== ""); + return team(teamApiKey, options, (s) => s.sequences.subscribers(sequenceId, query)); } export async function sendSendLitSequenceEmailTest( teamApiKey: string, sequenceId: string, emailId: string, - input: { to: string[] }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + input: SendSequenceEmailTestInput, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - `/sequences/${encodeURIComponent(sequenceId)}/emails/${encodeURIComponent(emailId)}/test`, - { method: "POST", teamApiKey, body: input }, - options.fetcher, + return team(teamApiKey, options, (s) => + s.sequences.sendEmailTest(sequenceId, emailId, input), ); } @@ -1306,36 +683,9 @@ export async function sendSendLitSequenceEmailTest( export async function trackSendLitEvent( teamApiKey: string, input: SendLitTrackEventInput, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, + options: SendLitClientOptions = {}, ): Promise { - const config = options.config ?? sendLitConfig(); - return requestJson( - config, - "/events", - { method: "POST", teamApiKey, body: input }, - options.fetcher, - ); -} - -// --------------------------------------------------------------------------- -// Overview / Metrics (Team API key scoped) -// --------------------------------------------------------------------------- - -export async function getSendLitOverview( - teamApiKey: string, - query: { rangeDays?: number } = {}, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, -): Promise { - const config = options.config ?? sendLitConfig(); - const search = new URLSearchParams(); - if (query.rangeDays !== undefined) search.set("rangeDays", String(query.rangeDays)); - const qs = search.toString(); - return requestJson( - config, - `/overview${qs ? `?${qs}` : ""}`, - { method: "GET", teamApiKey }, - options.fetcher, - ); + return team(teamApiKey, options, (s) => s.events.track(input)); } // --------------------------------------------------------------------------- @@ -1344,26 +694,8 @@ export async function getSendLitOverview( export async function sendSendLitTransactionalEmail( teamApiKey: string, - input: { - to: string; - templateId?: string; - html?: string; - variables?: Record; - text?: string; - /** The From name for this email; the address stays the team's. */ - fromName?: string; - replyTo?: string; - subject?: string; - headers?: Record; - idempotencyKey?: string; - }, - options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, -): Promise<{ txeId: string; status?: string }> { - const config = options.config ?? sendLitConfig(); - return requestJson<{ txeId: string; status?: string }>( - config, - "/emails", - { method: "POST", teamApiKey, body: input }, - options.fetcher, - ); + input: SendEmailInput, + options: SendLitClientOptions = {}, +): Promise { + return team(teamApiKey, options, (s) => s.emails.send(input)); } diff --git a/apps/api/src/sendlit-email-readiness.test.ts b/apps/api/src/sendlit-email-readiness.test.ts index 20c294906..3cb640f30 100644 --- a/apps/api/src/sendlit-email-readiness.test.ts +++ b/apps/api/src/sendlit-email-readiness.test.ts @@ -10,6 +10,7 @@ import { updateSequenceEmail, } from "./sendlit-service.js"; import { encryptIntegrationSecret } from "./utils/integration-secrets.js"; +import { jsonResponse } from "./sendlit-test-support.js"; const text = (content: string) => ({ blockType: "text", settings: { content } }); const placeholder = { @@ -142,20 +143,21 @@ describe("sending through the service", () => { const url = String(input); const method = init?.method ?? "GET"; if (url.includes("/settings/general")) { - return new Response(JSON.stringify({ mailingAddress: "12 Sprite Lane" })); + return jsonResponse({ mailingAddress: "12 Sprite Lane" }); } if (url.endsWith("/sequences/seq_1") && method === "GET") { - return new Response(JSON.stringify(state.sequence)); + return jsonResponse(state.sequence); } if (url.includes("/emails/e1") && method === "PATCH") { state.patched.push(JSON.parse(String(init?.body))); - return new Response(JSON.stringify(email())); + // SendLit answers an email update with the whole sequence. + return jsonResponse({ ...state.sequence, emailsOrder: ["e1"], emails: [email()] }); } if (url.includes("/start")) { state.started.push(url); - return new Response(JSON.stringify({ status: "active" })); + return jsonResponse({ status: "active" }); } - return new Response("{}", { status: 404 }); + return jsonResponse({}, 404); }) as typeof fetch; } diff --git a/apps/api/src/sendlit-mailing-address.test.ts b/apps/api/src/sendlit-mailing-address.test.ts index c7c094b87..0e4d2b7b6 100644 --- a/apps/api/src/sendlit-mailing-address.test.ts +++ b/apps/api/src/sendlit-mailing-address.test.ts @@ -6,6 +6,7 @@ import { createSchool } from "./schools.js"; import { seedWorld } from "./seed.js"; import { startSequence } from "./sendlit-service.js"; import { encryptIntegrationSecret } from "./utils/integration-secrets.js"; +import { jsonResponse } from "./sendlit-test-support.js"; describe("starting a sequence", () => { it("asks for a mailing address instead of failing with a vague error", async () => { @@ -52,11 +53,10 @@ describe("starting a sequence", () => { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.includes("/settings/general") && (init?.method ?? "GET") === "GET") { - return new Response(JSON.stringify({ mailingAddress })); + return jsonResponse({ mailingAddress }); } if (url.endsWith("/sequences/seq_1") && (init?.method ?? "GET") === "GET") { - return new Response( - JSON.stringify({ + return jsonResponse({ sequenceId: "seq_1", type: "broadcast", emails: [ @@ -66,14 +66,13 @@ describe("starting a sequence", () => { content: { content: [{ blockType: "text", settings: { content: "Hi all" } }] }, }, ], - }), - ); + }); } if (url.includes("/start")) { started.push(url); - return new Response(JSON.stringify({ status: "active" })); + return jsonResponse({ status: "active" }); } - return new Response("{}", { status: 404 }); + return jsonResponse({}, 404); }) as typeof fetch; try { diff --git a/apps/api/src/sendlit-sequence-subscribers.test.ts b/apps/api/src/sendlit-sequence-subscribers.test.ts index 08fcb7c6a..55ef0d22e 100644 --- a/apps/api/src/sendlit-sequence-subscribers.test.ts +++ b/apps/api/src/sendlit-sequence-subscribers.test.ts @@ -7,6 +7,7 @@ import { seedWorld } from "./seed.js"; import { listSendLitSequenceSubscribers } from "./sendlit-client.js"; import { listSequenceSubscribers } from "./sendlit-service.js"; import { encryptIntegrationSecret } from "./utils/integration-secrets.js"; +import { jsonResponse } from "./sendlit-test-support.js"; type Call = { url: string; method: string }; @@ -49,7 +50,7 @@ async function setup(handler: (url: URL) => Response) { } const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { status }); + jsonResponse(body, status); function sendLitFake(url: URL): Response { if (url.pathname === "/sequences/seq_1/subscribers") { @@ -81,7 +82,7 @@ describe("listSendLitSequenceSubscribers", () => { let seen = ""; const ids = await listSendLitSequenceSubscribers( "key", - "seq 1", + "seq_1", { page: 2, limit: 5 }, { config, @@ -92,7 +93,7 @@ describe("listSendLitSequenceSubscribers", () => { }, ); expect(ids).toEqual(["c_1", "c_2"]); - expect(seen).toBe("http://sendlit.test/sequences/seq%201/subscribers?page=2&limit=5"); + expect(seen).toBe("http://sendlit.test/sequences/seq_1/subscribers?page=2&limit=5"); }); it("omits the query string without paging", async () => { @@ -106,22 +107,6 @@ describe("listSendLitSequenceSubscribers", () => { }); expect(seen).toBe("http://sendlit.test/sequences/s/subscribers"); }); - - it("accepts an items envelope and drops non-string entries", async () => { - const ids = await listSendLitSequenceSubscribers("key", "s", {}, { - config, - fetcher: async () => json({ items: ["c_1", 7, "", "c_2"] }), - }); - expect(ids).toEqual(["c_1", "c_2"]); - }); - - it("returns nothing for an unexpected body", async () => { - const ids = await listSendLitSequenceSubscribers("key", "s", {}, { - config, - fetcher: async () => json({ nope: true }), - }); - expect(ids).toEqual([]); - }); }); describe("listSequenceSubscribers", () => { diff --git a/apps/api/src/sendlit-service.test.ts b/apps/api/src/sendlit-service.test.ts index 47a07cb3a..ed9a3eab1 100644 --- a/apps/api/src/sendlit-service.test.ts +++ b/apps/api/src/sendlit-service.test.ts @@ -19,7 +19,6 @@ import { deleteTemplate, duplicateTemplate, getMailingSettings, - getOverview, getSegment, getSequence, getSequenceStats, @@ -36,11 +35,33 @@ import { updateSegment, updateSequence, updateSequenceEmail, - updateSubscriber, updateTemplate, } from "./sendlit-service.js"; import { encryptIntegrationSecret } from "./utils/integration-secrets.js"; import { dispatch } from "./dispatch.js"; +import { + jsonResponse, + sendLitContact, + sendLitEmail, + sendLitSegment, + sendLitSequence, + sendLitTemplate, +} from "./sendlit-test-support.js"; + +/** A valid email body, standing in for SendLit's `system:blank` starter. */ +const BLANK_SYSTEM_CONTENT = { + style: { + colors: { + background: "#ffffff", + foreground: "#000000", + border: "#e5e5e5", + accent: "#2563eb", + accentForeground: "#ffffff", + }, + }, + meta: { previewText: "" }, + content: [{ blockType: "text", settings: { content: "Hello" } }], +}; describe("SendLit Service", () => { it("manages all SendLit mailing and contact resources cleanly through CourseLit domain abstractions", async () => { @@ -100,36 +121,41 @@ describe("SendLit Service", () => { if (url.includes("/settings/general")) { if (method === "GET") { - return new Response(JSON.stringify({ mailingAddress: "123 School Lane" })); + return jsonResponse({ mailingAddress: "123 School Lane" }); } if (method === "PUT") { - return new Response(JSON.stringify({ mailingAddress: body.mailingAddress })); + return jsonResponse({ mailingAddress: body.mailingAddress }); } } if (url.includes("/contacts")) { if (url.includes("/tags/") && method === "POST") { - return new Response(JSON.stringify({ contactId: "c_1", email: "learner@test.com", tags: ["vip"] })); + return jsonResponse(sendLitContact({ tags: ["vip"] })); } if (url.includes("/tags/") && method === "DELETE") { - return new Response(JSON.stringify({ contactId: "c_1", email: "learner@test.com", tags: [] })); + return jsonResponse(sendLitContact()); } if (method === "GET") { - return new Response( - JSON.stringify({ + return jsonResponse({ items: [ - { contactId: "c_1", email: "learner1@test.com", tags: ["vip", "spring"] }, - { contactId: "c_2", email: "learner2@test.com", tags: ["vip"] }, + sendLitContact({ email: "learner1@test.com", tags: ["vip", "spring"] }), + sendLitContact({ + contactId: "c_2", + email: "learner2@test.com", + tags: ["vip"], + }), ], total: 2, - }), - ); + }); } if (method === "POST") { - return new Response(JSON.stringify({ contactId: "c_new", email: body.email, tags: body.tags ?? [] })); + return jsonResponse( + sendLitContact({ contactId: "c_new", email: body.email, tags: body.tags ?? [] }), + 201, + ); } if (method === "PUT" || method === "PATCH") { - return new Response(JSON.stringify({ contactId: "c_1", email: body.email, tags: body.tags ?? [] })); + return jsonResponse(sendLitContact({ tags: body.tags ?? [] })); } if (method === "DELETE") { return new Response(null, { status: 204 }); @@ -138,13 +164,13 @@ describe("SendLit Service", () => { if (url.includes("/segments")) { if (method === "GET") { - return new Response(JSON.stringify([{ id: "seg_1", name: "VIP Segment" }])); + return jsonResponse([sendLitSegment()]); } if (method === "POST") { - return new Response(JSON.stringify({ id: "seg_2", name: body.name })); + return jsonResponse(sendLitSegment({ segmentId: "seg_2", name: body.name }), 201); } if (method === "PUT" || method === "PATCH") { - return new Response(JSON.stringify({ id: "seg_1", name: body.name })); + return jsonResponse(sendLitSegment({ name: body.name })); } if (method === "DELETE") { return new Response(null, { status: 204 }); @@ -153,76 +179,99 @@ describe("SendLit Service", () => { if (url.includes("/sequences")) { if (url.includes("/stats")) { - return new Response(JSON.stringify({ sent: 10, delivered: 9, opened: 5, clicked: 2 })); + return jsonResponse({ + sent: 10, + openRate: 0.5, + clickThroughRate: 0.2, + subscribersCount: 9, + }); } if (url.includes("/start")) { - return new Response(JSON.stringify({ status: "active" })); + return jsonResponse(sendLitSequence({ status: "active" })); } if (url.includes("/pause")) { - return new Response(JSON.stringify({ status: "paused" })); + return jsonResponse(sendLitSequence({ status: "paused" })); } if (url.includes("/emails") && method === "POST") { const sequenceId = url.match(/\/sequences\/([^/]+)\/emails/)?.[1] ?? "seq_1"; - return new Response( - JSON.stringify({ + return jsonResponse( + sendLitSequence({ sequenceId, emailsOrder: ["em_1"], - emails: [ - { - emailId: "em_1", - subject: "Welcome", - content: { content: [] }, - delayInMillis: 24 * 60 * 60 * 1000, - published: false, - }, - ], + emails: [sendLitEmail()], }), - { status: 201 }, + 201, ); } if (url.includes("/emails/") && (method === "PATCH" || method === "PUT")) { - return new Response(JSON.stringify({ id: "em_1", subject: body.subject })); + return jsonResponse( + sendLitSequence({ + emailsOrder: ["em_1"], + emails: [sendLitEmail({ subject: body.subject })], + }), + ); } if (url.includes("/emails/") && method === "DELETE") { return new Response(null, { status: 204 }); } if (method === "GET") { - return new Response(JSON.stringify({ items: [{ id: "seq_1", title: "Welcome Sequence" }], total: 1 })); + return jsonResponse({ items: [sendLitSequence()], total: 1 }); } if (method === "POST") { - return new Response(JSON.stringify({ id: "seq_2", title: body.title, type: body.type })); + return jsonResponse( + // SendLit ignores a title on create; the client sets it with an update. + sendLitSequence({ sequenceId: "seq_2", title: "Untitled", type: body.type }), + 201, + ); } if (method === "PUT" || method === "PATCH") { - return new Response(JSON.stringify({ id: "seq_1", title: body.title })); + return jsonResponse( + sendLitSequence({ + sequenceId: url.match(/\/sequences\/([^/]+)$/)?.[1] ?? "seq_1", + title: body.title, + }), + ); } if (method === "DELETE") { return new Response(null, { status: 204 }); } } + if (url.includes("/system-templates")) { + return jsonResponse({ + items: [ + { + templateId: "system:blank", + title: "Blank", + description: "A blank marketing starter with a managed footer.", + purpose: "marketing", + content: BLANK_SYSTEM_CONTENT, + requiredVariables: [], + variableDefinitions: [], + }, + ], + }); + } + if (url.includes("/templates")) { if (url.includes("/duplicate")) { - return new Response(JSON.stringify({ id: "tmpl_2", name: "Welcome Copy" })); + return jsonResponse(sendLitTemplate({ templateId: "tmpl_2", title: "Welcome Copy" }), 201); } if (method === "GET") { - return new Response(JSON.stringify({ items: [{ id: "tmpl_1", name: "Welcome Template" }] })); + return jsonResponse([sendLitTemplate()]); } if (method === "POST") { - return new Response(JSON.stringify({ id: "tmpl_3", name: body.name })); + return jsonResponse(sendLitTemplate({ templateId: "tmpl_3", title: body.title }), 201); } if (method === "PUT" || method === "PATCH") { - return new Response(JSON.stringify({ id: "tmpl_1", name: body.name })); + return jsonResponse(sendLitTemplate({ title: body.title })); } if (method === "DELETE") { return new Response(null, { status: 204 }); } } - if (url.includes("/overview")) { - return new Response(JSON.stringify({ totalSubscribers: 100, activeSubscribers: 95 })); - } - - return new Response(JSON.stringify({ ok: true })); + return jsonResponse({ ok: true }); }) as any; try { @@ -255,11 +304,6 @@ describe("SendLit Service", () => { }); expect(createdSub.ok).toBe(true); - const updatedSub = await updateSubscriber(runtime.db, schoolId, clock, "c_1", { - email: "updated@test.com", - }); - expect(updatedSub.ok).toBe(true); - const deletedSub = await deleteSubscriber(runtime.db, schoolId, clock, "c_1"); expect(deletedSub.ok).toBe(true); @@ -284,7 +328,9 @@ describe("SendLit Service", () => { expect(deletedSeg.ok).toBe(true); // 5. Sequences - const sequences = await listSequences(runtime.db, schoolId, clock); + const sequences = await listSequences(runtime.db, schoolId, clock, { + type: "sequence", + }); expect(sequences.ok).toBe(true); const createdSeq = await createSequence(runtime.db, schoolId, clock, { @@ -382,6 +428,14 @@ describe("SendLit Service", () => { name: "Welcome Newsletter", }); expect(createdTmpl.ok).toBe(true); + // Without content, the template starts from SendLit's blank starter. + const templateCreate = fetchCalls.find( + (call) => call.method === "POST" && call.url.endsWith("/templates"), + ); + expect(templateCreate?.body).toMatchObject({ + title: "Welcome Newsletter", + content: BLANK_SYSTEM_CONTENT, + }); const dupTmpl = await duplicateTemplate(runtime.db, schoolId, clock, "tmpl_1"); expect(dupTmpl.ok).toBe(true); @@ -393,11 +447,6 @@ describe("SendLit Service", () => { const deletedTmpl = await deleteTemplate(runtime.db, schoolId, clock, "tmpl_1"); expect(deletedTmpl.ok).toBe(true); - - // 7. Overview - const overview = await getOverview(runtime.db, schoolId, clock); - expect(overview.ok).toBe(true); - if (overview.ok) expect(overview.value.totalSubscribers).toBe(100); } finally { globalThis.fetch = originalFetch; await runtime.close(); @@ -436,24 +485,24 @@ describe("SendLit Service", () => { const body = init?.body ? JSON.parse(init.body as string) : undefined; if (url.includes("/settings/general")) { - return new Response(JSON.stringify({ mailingAddress: body?.mailingAddress ?? "100 Education Way" })); + return jsonResponse({ mailingAddress: body?.mailingAddress ?? "100 Education Way" }); } if (url.includes("/contacts")) { - return new Response(JSON.stringify({ items: [{ contactId: "c_1", email: "learner@test.com", tags: ["vip"] }], total: 1 })); + return jsonResponse({ items: [sendLitContact({ tags: ["vip"] })], total: 1 }); } if (url.includes("/segments")) { - return new Response(JSON.stringify([{ id: "seg_1", name: "High Achievers" }])); + return jsonResponse([sendLitSegment({ name: "High Achievers" })]); } if (url.includes("/sequences")) { - return new Response(JSON.stringify({ items: [{ id: "seq_1", title: "Welcome Flow" }], total: 1 })); + return jsonResponse({ + items: [sendLitSequence({ title: "Welcome Flow" })], + total: 1, + }); } if (url.includes("/templates")) { - return new Response(JSON.stringify({ items: [{ id: "tmpl_1", name: "Clean Newsletter" }] })); - } - if (url.includes("/overview")) { - return new Response(JSON.stringify({ totalSubscribers: 50, activeSubscribers: 48 })); + return jsonResponse([sendLitTemplate({ title: "Clean Newsletter" })]); } - return new Response(JSON.stringify({ ok: true })); + return jsonResponse({ ok: true }); }) as any; try { @@ -482,14 +531,21 @@ describe("SendLit Service", () => { expect(patchSettings.body).toMatchObject({ mailingAddress: "200 University Ave" }); - // 6. GET /v1/school/mails/sequences - const getSequencesRes = await dispatch(runtime, { + // 6. GET /v1/school/mails/sequences needs a type: SendLit lists + // broadcasts and sequences separately. + const untypedSequencesRes = await dispatch(runtime, { method: "GET", path: "/v1/school/mails/sequences", headers: adminHeaders, }); + expect(untypedSequencesRes.status).toBe(400); + const getSequencesRes = await dispatch(runtime, { + method: "GET", + path: "/v1/school/mails/sequences?type=sequence", + headers: adminHeaders, + }); expect(getSequencesRes.status).toBe(200); - expect(getSequencesRes.body).toMatchObject({ items: [{ id: "seq_1" }] }); + expect(getSequencesRes.body).toMatchObject({ items: [{ sequenceId: "seq_1" }] }); // 7. GET /v1/school/mails/templates const getTemplatesRes = await dispatch(runtime, { @@ -498,7 +554,7 @@ describe("SendLit Service", () => { headers: adminHeaders, }); expect(getTemplatesRes.status).toBe(200); - expect(getTemplatesRes.body).toMatchObject({ items: [{ id: "tmpl_1" }] }); + expect(getTemplatesRes.body).toMatchObject({ items: [{ templateId: "tmpl_1" }] }); } finally { globalThis.fetch = originalFetch; await runtime.close(); diff --git a/apps/api/src/sendlit-service.ts b/apps/api/src/sendlit-service.ts index 8afbd4ff7..3ef508616 100644 --- a/apps/api/src/sendlit-service.ts +++ b/apps/api/src/sendlit-service.ts @@ -27,7 +27,6 @@ import { duplicateSendLitTemplate, getSendLitContact, getSendLitGeneralSettings, - getSendLitOverview, getSendLitSegment, getSendLitSequence, getSendLitSequenceStats, @@ -45,7 +44,6 @@ import { sendLitConfig, sendSendLitSequenceEmailTest, startSendLitSequence, - updateSendLitContact, updateSendLitGeneralSettings, updateSendLitSegment, updateSendLitSequence, @@ -55,7 +53,10 @@ import { type SendLitContact, type SendLitEmailTestResult, type SendLitGeneralSettings, - type SendLitOverview, + type SendLitCreateSequenceInput, + type SendLitCustomFields, + type SendLitUpdateSequenceEmailInput, + type SendLitUpdateSequenceInput, type SendLitSegment, type SendLitSegmentFilter, type SendLitSequence, @@ -475,7 +476,6 @@ export async function listSubscribers( offset?: number; limit?: number; search?: string; - tag?: string; status?: string; filter?: string; cursor?: string; @@ -493,7 +493,6 @@ export async function listSubscribers( offset: query.offset, rowsPerPage: query.limit, q: query.search, - tag: query.tag, filter: query.filter ?? query.status, }, { config: cfg }, @@ -510,7 +509,7 @@ export async function createSubscriber( email: string; name?: string; tags?: string[]; - customFields?: Record; + customFields?: SendLitCustomFields; }, config?: SendLitConfig, ): Promise> { @@ -539,30 +538,6 @@ export async function getSubscriber( ); } -export async function updateSubscriber( - db: AppDb, - schoolId: string, - clock: Clock, - contactId: string, - input: { - email?: string; - name?: string; - subscribed?: boolean; - tags?: string[]; - customFields?: Record; - }, - config?: SendLitConfig, -): Promise> { - return withSendLitAuth( - db, - schoolId, - clock, - (auth, cfg) => - updateSendLitContact(auth.teamApiKey, contactId, input, { config: cfg }), - config, - ); -} - export async function deleteSubscriber( db: AppDb, schoolId: string, @@ -674,12 +649,12 @@ export async function listSequences( schoolId: string, clock: Clock, query: { - type?: "broadcast" | "sequence" | string; + type: "broadcast" | "sequence"; status?: string; search?: string; cursor?: string; limit?: number; - } = {}, + }, config?: SendLitConfig, ): Promise> { return withSendLitAuth( @@ -690,10 +665,7 @@ export async function listSequences( const res = await listSendLitSequences( auth.teamApiKey, { - type: - query.type === "broadcast" || query.type === "sequence" - ? query.type - : undefined, + type: query.type, itemsPerPage: query.limit, }, { config: cfg }, @@ -748,7 +720,7 @@ export async function createSequence( schoolId, clock, async (auth, cfg) => { - let created = await createSendLitSequence(auth.teamApiKey, normalizedInput, { + let created = await createSendLitSequence(auth.teamApiKey, normalizedInput as SendLitCreateSequenceInput, { config: cfg, }); if (normalizedInput.title && created.title !== normalizedInput.title) { @@ -833,7 +805,7 @@ export async function updateSequence( await updateSendLitSequence( auth.teamApiKey, sequenceId, - toSendLitTrigger(input), + toSendLitTrigger(input) as SendLitUpdateSequenceInput, { config: cfg, }, @@ -913,7 +885,7 @@ export async function updateSequenceEmail( if (problem) throw new EmailNotReadyError(problem, emailId, null); } } - return updateSendLitSequenceEmail(auth.teamApiKey, sequenceId, emailId, input, { + return updateSendLitSequenceEmail(auth.teamApiKey, sequenceId, emailId, input as SendLitUpdateSequenceEmailInput, { config: cfg, }); }, @@ -965,9 +937,10 @@ export async function sendSequenceEmailTest( * is its one email, so that email must be ready; in a sequence only published * emails are sent, so drafts may still be placeholders. */ -export function assertSequenceEmailsReady( - sequence: Pick, -): void { +export function assertSequenceEmailsReady(sequence: { + type: string; + emails?: { emailId?: string; subject: string; content: unknown; published: boolean }[]; +}): void { const emails = sequence.emails ?? []; if (sequence.type === "broadcast" && emails.length === 0) { throw new EmailNotReadyError("body_empty", null, null); @@ -1156,6 +1129,26 @@ export async function listTemplates( ); } +/** SendLit's built-in blank template, used when a template is created + * without content. */ +const BLANK_SYSTEM_TEMPLATE_ID = "system:blank"; + +async function blankTemplateContent( + teamApiKey: string, + options: { config: SendLitConfig }, +): Promise { + const { items } = await listSendLitSystemTemplates(teamApiKey, options); + const blank = items.find((item) => item.templateId === BLANK_SYSTEM_TEMPLATE_ID); + if (!blank) { + throw new SendLitApiError( + `SendLit has no ${BLANK_SYSTEM_TEMPLATE_ID} template to start from`, + undefined, + false, + ); + } + return blank.content as SendLitTemplateContent; +} + export async function createTemplate( db: AppDb, schoolId: string, @@ -1173,12 +1166,16 @@ export async function createTemplate( db, schoolId, clock, - (auth, cfg) => + async (auth, cfg) => createSendLitTemplate( auth.teamApiKey, { title: input.title ?? input.name ?? "Untitled Template", - content: input.content ?? { content: { html: "" } }, + // Without content, start from SendLit's own blank starter, which + // is a complete, valid email (style, meta and footer). + content: + input.content ?? + (await blankTemplateContent(auth.teamApiKey, { config: cfg })), purpose: input.purpose, }, { config: cfg }, @@ -1227,8 +1224,7 @@ export async function updateTemplate( templateId, { title: input.title ?? input.name, - content: input.content, - purpose: input.purpose, + content: input.content as SendLitTemplateContent | undefined, }, { config: cfg }, ), @@ -1269,21 +1265,3 @@ export async function deleteTemplate( ); } -// --------------------------------------------------------------------------- -// Overview -// --------------------------------------------------------------------------- - -export async function getOverview( - db: AppDb, - schoolId: string, - clock: Clock, - config?: SendLitConfig, -): Promise> { - return withSendLitAuth( - db, - schoolId, - clock, - (auth, cfg) => getSendLitOverview(auth.teamApiKey, {}, { config: cfg }), - config, - ); -} diff --git a/apps/api/src/sendlit-test-support.ts b/apps/api/src/sendlit-test-support.ts new file mode 100644 index 000000000..ef4d10f7e --- /dev/null +++ b/apps/api/src/sendlit-test-support.ts @@ -0,0 +1,69 @@ +/** SendLit answers JSON with `content-type: application/json`, and the SDK + * only parses bodies that say so. Test fakes of SendLit use this. */ +export function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** SendLit objects as its contract returns them (`@sendlit/api-contract`). */ +export const sendLitContact = (over: Record = {}) => ({ + contactId: "c_1", + email: "learner@test.com", + name: null, + subscribed: true, + customFields: {}, + tags: [], + unsubscribeToken: "tok_1", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + ...over, +}); + +export const sendLitSegment = (over: Record = {}) => ({ + segmentId: "seg_1", + name: "VIP Segment", + filter: { aggregator: "and", filters: [] }, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + ...over, +}); + +export const sendLitSequence = (over: Record = {}) => ({ + sequenceId: "seq_1", + type: "sequence", + title: "Welcome Sequence", + status: "draft", + deliverySource: null, + emailsOrder: [], + entrants: [], + report: {}, + viewInBrowser: false, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + emails: [], + ...over, +}); + +export const sendLitTemplate = (over: Record = {}) => ({ + templateId: "tmpl_1", + title: "Welcome Template", + purpose: "marketing", + content: { style: {}, meta: {}, content: [] }, + requiredVariables: [], + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + ...over, +}); + +export const sendLitEmail = (over: Record = {}) => ({ + emailId: "em_1", + subject: "Welcome", + content: { style: {}, meta: {}, content: [] }, + delayInMillis: 24 * 60 * 60 * 1000, + published: false, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + ...over, +}); diff --git a/apps/api/src/usage.test.ts b/apps/api/src/usage.test.ts index ff20771d0..e1564bec5 100644 --- a/apps/api/src/usage.test.ts +++ b/apps/api/src/usage.test.ts @@ -29,6 +29,10 @@ const deps = (t: Setup, accepted = 0) => ({ limit: planLimits().pro.marketing_emails_month, accepted, reserved: 0, + remaining: + planLimits().pro.marketing_emails_month === null + ? null + : planLimits().pro.marketing_emails_month! - accepted, resetsAt: "2026-04-01T00:00:00.000Z", }), }); diff --git a/bun.lock b/bun.lock index 767daf23f..852d7382f 100644 --- a/bun.lock +++ b/bun.lock @@ -83,6 +83,7 @@ "nodemailer": "^10.0.1", "pg": "8.23.0", "pino": "9.6.0", + "sendlit": "^0.5.0", "swagger-ui-express": "5.0.1", "zod": "3.25.76", }, @@ -1752,6 +1753,8 @@ "send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], + "sendlit": ["sendlit@0.5.0", "", { "dependencies": { "@ts-rest/core": "3.52.1", "zod": "3.25.76" } }, "sha512-/12k3g/cOfLpU7QXGhoegPWWk7ISGN68mgb6VYtB3ygsowKhzPShrguhD10t8H8ZWLHGAijEqLe57B9FeTkYLw=="], + "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 90df93fc8..4dae398b1 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -2260,7 +2260,8 @@ export const updateMailingSettingsBodySchema = z .object({ mailingAddress: z.string().trim().min(1).max(1000) }) .passthrough(); export const mailSequenceListQuerySchema = z.object({ - type: z.enum(["broadcast", "sequence"]).optional(), + /** Broadcasts and sequences are listed separately. */ + type: z.enum(["broadcast", "sequence"]), status: z.string().optional(), search: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).optional(),