From b1a0c7935bfd7a7f1d2429b884a6c92a202413c8 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 15 Nov 2026 20:42:22 +0530 Subject: [PATCH 1/3] refactor(api): talk to sendlit through the official sdk sendlit-client.ts called SendLit's API by hand with its own response types, and those had drifted from SendLit's contract. It now uses the sendlit SDK, whose types come from that contract, and keeps only what CourseLit adds: config, SendLitApiError and a little normalisation. - errors map from the SDK's SendLitError in one place; the quota and plan-limit checks use SendLit's error code instead of message text - no automatic retries (the outbox retries) and the same 15s timeout - removed getOverview and updateSubscriber, which nothing called - test fakes now return SendLit's real response shapes --- apps/api/src/contact-tags.test.ts | 3 +- apps/api/src/contacts-paging.test.ts | 3 +- apps/api/src/integration-provisioning.test.ts | 30 +- apps/api/src/integration-provisioning.ts | 12 +- apps/api/src/learner-profile-update.test.ts | 1 + apps/api/src/learner-profile.test.ts | 1 + apps/api/src/learner-profile.ts | 5 +- apps/api/src/mail-automation.test.ts | 15 +- apps/api/src/marketing-quota.test.ts | 11 +- apps/api/src/sendlit-client.test.ts | 36 +- apps/api/src/sendlit-client.ts | 1258 ++++------------- apps/api/src/sendlit-email-readiness.test.ts | 12 +- apps/api/src/sendlit-mailing-address.test.ts | 13 +- .../src/sendlit-sequence-subscribers.test.ts | 23 +- apps/api/src/sendlit-service.test.ts | 140 +- apps/api/src/sendlit-service.ts | 79 +- apps/api/src/sendlit-test-support.ts | 69 + apps/api/src/usage.test.ts | 4 + 18 files changed, 558 insertions(+), 1157 deletions(-) create mode 100644 apps/api/src/sendlit-test-support.ts 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/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 b120651fa..1ccfbb9e6 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 200726545..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,24 +694,8 @@ export async function getSendLitOverview( export async function sendSendLitTransactionalEmail( teamApiKey: string, - input: { - to: string; - templateId?: string; - html?: string; - variables?: Record; - text?: 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..e232f15a2 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,18 @@ 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"; describe("SendLit Service", () => { it("manages all SendLit mailing and contact resources cleanly through CourseLit domain abstractions", async () => { @@ -100,36 +106,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 +149,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,47 +164,58 @@ 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 }); @@ -202,27 +224,23 @@ describe("SendLit Service", () => { 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 +273,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); @@ -393,11 +406,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 +444,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 { @@ -489,7 +497,7 @@ describe("SendLit Service", () => { 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 +506,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..ea56acbc8 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, @@ -55,7 +54,11 @@ import { type SendLitContact, type SendLitEmailTestResult, type SendLitGeneralSettings, - type SendLitOverview, + type SendLitCreateSequenceInput, + type SendLitCustomFields, + type SendLitListSequencesQuery, + type SendLitUpdateSequenceEmailInput, + type SendLitUpdateSequenceInput, type SendLitSegment, type SendLitSegmentFilter, type SendLitSequence, @@ -475,7 +478,6 @@ export async function listSubscribers( offset?: number; limit?: number; search?: string; - tag?: string; status?: string; filter?: string; cursor?: string; @@ -493,7 +495,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 +511,7 @@ export async function createSubscriber( email: string; name?: string; tags?: string[]; - customFields?: Record; + customFields?: SendLitCustomFields; }, config?: SendLitConfig, ): Promise> { @@ -539,30 +540,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, @@ -690,12 +667,14 @@ export async function listSequences( const res = await listSendLitSequences( auth.teamApiKey, { + // SendLit requires `type`; without one it answers 400, which is + // what the caller gets. type: query.type === "broadcast" || query.type === "sequence" ? query.type : undefined, itemsPerPage: query.limit, - }, + } as SendLitListSequencesQuery, { config: cfg }, ); let items = res.items; @@ -748,7 +727,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 +812,7 @@ export async function updateSequence( await updateSendLitSequence( auth.teamApiKey, sequenceId, - toSendLitTrigger(input), + toSendLitTrigger(input) as SendLitUpdateSequenceInput, { config: cfg, }, @@ -913,7 +892,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 +944,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); @@ -1178,7 +1158,11 @@ export async function createTemplate( auth.teamApiKey, { title: input.title ?? input.name ?? "Untitled Template", - content: input.content ?? { content: { html: "" } }, + // The default is not a valid SendLit template body, so SendLit + // rejects a create without content. + content: (input.content ?? { + content: { html: "" }, + }) as SendLitTemplateContent, purpose: input.purpose, }, { config: cfg }, @@ -1227,8 +1211,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 +1252,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", }), }); From 7ce80f0c70fa29dbb37ee0d42ee5b2fe3f2b0870 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 15 Nov 2026 21:14:07 +0530 Subject: [PATCH 2/3] build(api): depend on sendlit 0.5.0 --- apps/api/package.json | 1 + bun.lock | 3 +++ 2 files changed, 4 insertions(+) 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/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=="], From bfabcaef695060ce53bace41eae5dcf9d06b0f90 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 15 Nov 2026 21:25:03 +0530 Subject: [PATCH 3/3] fix(api): start empty templates from sendlit's blank and require a sequence type - A template created without content sent { content: { html: "" } }, which SendLit rejects. It now starts from SendLit's own system:blank starter. - Listing broadcasts or sequences without a type reached SendLit, which requires one, and failed with its 400. The type is now required in the API contract and checked before calling SendLit. --- apps/api/openapi.json | 1 + apps/api/src/dispatch.ts | 10 +++++- apps/api/src/sendlit-service.test.ts | 54 ++++++++++++++++++++++++++-- apps/api/src/sendlit-service.ts | 47 +++++++++++++++--------- packages/api-contract/src/index.ts | 3 +- 5 files changed, 93 insertions(+), 22 deletions(-) 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/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/sendlit-service.test.ts b/apps/api/src/sendlit-service.test.ts index e232f15a2..ed9a3eab1 100644 --- a/apps/api/src/sendlit-service.test.ts +++ b/apps/api/src/sendlit-service.test.ts @@ -48,6 +48,21 @@ import { 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 () => { process.env.AUTH_SECRET = "test-secret-that-is-at-least-thirty-two-characters"; @@ -222,6 +237,22 @@ describe("SendLit Service", () => { } } + 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 jsonResponse(sendLitTemplate({ templateId: "tmpl_2", title: "Welcome Copy" }), 201); @@ -297,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, { @@ -395,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); @@ -490,12 +531,19 @@ 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: [{ sequenceId: "seq_1" }] }); diff --git a/apps/api/src/sendlit-service.ts b/apps/api/src/sendlit-service.ts index ea56acbc8..3ef508616 100644 --- a/apps/api/src/sendlit-service.ts +++ b/apps/api/src/sendlit-service.ts @@ -44,7 +44,6 @@ import { sendLitConfig, sendSendLitSequenceEmailTest, startSendLitSequence, - updateSendLitContact, updateSendLitGeneralSettings, updateSendLitSegment, updateSendLitSequence, @@ -56,7 +55,6 @@ import { type SendLitGeneralSettings, type SendLitCreateSequenceInput, type SendLitCustomFields, - type SendLitListSequencesQuery, type SendLitUpdateSequenceEmailInput, type SendLitUpdateSequenceInput, type SendLitSegment, @@ -651,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( @@ -667,14 +665,9 @@ export async function listSequences( const res = await listSendLitSequences( auth.teamApiKey, { - // SendLit requires `type`; without one it answers 400, which is - // what the caller gets. - type: - query.type === "broadcast" || query.type === "sequence" - ? query.type - : undefined, + type: query.type, itemsPerPage: query.limit, - } as SendLitListSequencesQuery, + }, { config: cfg }, ); let items = res.items; @@ -1136,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, @@ -1153,16 +1166,16 @@ export async function createTemplate( db, schoolId, clock, - (auth, cfg) => + async (auth, cfg) => createSendLitTemplate( auth.teamApiKey, { title: input.title ?? input.name ?? "Untitled Template", - // The default is not a valid SendLit template body, so SendLit - // rejects a create without content. - content: (input.content ?? { - content: { html: "" }, - }) as SendLitTemplateContent, + // 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 }, 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(),