From 2357469968aba223ad266270903ed925e15cbae3 Mon Sep 17 00:00:00 2001 From: Jack <72348727+Jack-GitHub12@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:45:51 -0500 Subject: [PATCH] feat: add Lemlist integration (issue #20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Lemlist cold-email integration with 6 operations backed by 57 unit tests: - lemlistAddLeadNode: POST /campaigns/{id}/leads/ - lemlistGetCampaignsNode: GET /campaigns?version=v2 - lemlistGetActivityNode: GET /activities?version=v2 - lemlistPauseLeadNode: POST /leads/pause/{leadId} - lemlistResumeLeadNode: POST /leads/start/{leadId} - lemlistMarkAsInterestedNode: POST /campaigns/{id}/leads/{leadIdOrEmail}/interested Also includes lemlistCredential (apiKey) and Zod schemas for all inputs/outputs. Auth uses HTTP Basic with empty username + API key as password (Lemlist's real auth scheme, not the simplified example in the issue). All HTTP goes through the shared fetchWithRetry helper for retries/timeouts/rate-limit handling — nodes stay pure per the architectural decision in CLAUDE.md. URL-encoding of path identifiers (leadId, campaignId, leadIdOrEmail) uses encodeURIComponent, with test coverage for special characters including email-form leads and characters like '+' and '/'. Drive-by: fixes a pre-existing broken import path in packages/nodes/src/integrations/google-sheets/googleSheets.ts ('../types/credentials.js' -> '@jam-nodes/core') that was blocking package-root module loads and therefore blocking cross-package export verification for the new Lemlist tests. Tests: 252 passing (+57 new), 0 regressions. --- packages/core/src/types/node.ts | 4 + packages/nodes/src/index.ts | 52 + .../google-sheets/googleSheets.ts | 2 +- packages/nodes/src/integrations/index.ts | 41 + .../lemlist/__tests__/lemlist.test.ts | 902 ++++++++++++++++++ .../src/integrations/lemlist/add-lead.ts | 81 ++ .../src/integrations/lemlist/credentials.ts | 17 + .../src/integrations/lemlist/get-activity.ts | 79 ++ .../src/integrations/lemlist/get-campaigns.ts | 77 ++ .../nodes/src/integrations/lemlist/index.ts | 47 + .../lemlist/mark-as-interested.ts | 67 ++ .../src/integrations/lemlist/pause-lead.ts | 73 ++ .../src/integrations/lemlist/resume-lead.ts | 73 ++ .../nodes/src/integrations/lemlist/schemas.ts | 180 ++++ .../nodes/src/integrations/lemlist/utils.ts | 21 + 15 files changed, 1715 insertions(+), 1 deletion(-) create mode 100644 packages/nodes/src/integrations/lemlist/__tests__/lemlist.test.ts create mode 100644 packages/nodes/src/integrations/lemlist/add-lead.ts create mode 100644 packages/nodes/src/integrations/lemlist/credentials.ts create mode 100644 packages/nodes/src/integrations/lemlist/get-activity.ts create mode 100644 packages/nodes/src/integrations/lemlist/get-campaigns.ts create mode 100644 packages/nodes/src/integrations/lemlist/index.ts create mode 100644 packages/nodes/src/integrations/lemlist/mark-as-interested.ts create mode 100644 packages/nodes/src/integrations/lemlist/pause-lead.ts create mode 100644 packages/nodes/src/integrations/lemlist/resume-lead.ts create mode 100644 packages/nodes/src/integrations/lemlist/schemas.ts create mode 100644 packages/nodes/src/integrations/lemlist/utils.ts diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts index 45327ef..b3520bd 100644 --- a/packages/core/src/types/node.ts +++ b/packages/core/src/types/node.ts @@ -66,6 +66,10 @@ export interface NodeCredentials { devto?: { apiKey: string } + /** Lemlist API credentials */ + lemlist?: { + apiKey: string + } /** WordPress Application Password credentials */ wordpress?: { siteUrl: string diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 97e62dc..4ee727a 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -196,6 +196,29 @@ export { readOutputSchema, updateInputSchema, updateOutputSchema, + // Lemlist + lemlistAddLeadNode, + lemlistGetCampaignsNode, + lemlistGetActivityNode, + lemlistPauseLeadNode, + lemlistResumeLeadNode, + lemlistMarkAsInterestedNode, + LemlistLeadSchema, + LemlistCampaignSchema, + LemlistActivitySchema, + LemlistAddLeadInputSchema, + LemlistAddLeadOutputSchema, + LemlistGetCampaignsInputSchema, + LemlistGetCampaignsOutputSchema, + LemlistGetActivityInputSchema, + LemlistGetActivityOutputSchema, + LemlistPauseLeadInputSchema, + LemlistPauseLeadOutputSchema, + LemlistResumeLeadInputSchema, + LemlistResumeLeadOutputSchema, + LemlistMarkAsInterestedInputSchema, + LemlistMarkAsInterestedOutputSchema, + lemlistCredential, } from './integrations/index.js' export type { @@ -276,6 +299,22 @@ export type { ReadOutput, UpdateInput, UpdateOutput, + // Lemlist + LemlistLead, + LemlistCampaign, + LemlistActivity, + LemlistAddLeadInput, + LemlistAddLeadOutput, + LemlistGetCampaignsInput, + LemlistGetCampaignsOutput, + LemlistGetActivityInput, + LemlistGetActivityOutput, + LemlistPauseLeadInput, + LemlistPauseLeadOutput, + LemlistResumeLeadInput, + LemlistResumeLeadOutput, + LemlistMarkAsInterestedInput, + LemlistMarkAsInterestedOutput, } from './integrations/index.js' // AI nodes @@ -348,6 +387,12 @@ import { googleSheetsClearNode, googleSheetsReadNode, googleSheetsUpdateNode, + lemlistAddLeadNode, + lemlistGetCampaignsNode, + lemlistGetActivityNode, + lemlistPauseLeadNode, + lemlistResumeLeadNode, + lemlistMarkAsInterestedNode, } from './integrations/index.js' import { socialKeywordGeneratorNode, @@ -407,6 +452,13 @@ export const builtInNodes = [ googleSheetsClearNode, googleSheetsReadNode, googleSheetsUpdateNode, + // Lemlist + lemlistAddLeadNode, + lemlistGetCampaignsNode, + lemlistGetActivityNode, + lemlistPauseLeadNode, + lemlistResumeLeadNode, + lemlistMarkAsInterestedNode, // AI socialKeywordGeneratorNode, draftEmailsNode, diff --git a/packages/nodes/src/integrations/google-sheets/googleSheets.ts b/packages/nodes/src/integrations/google-sheets/googleSheets.ts index 488a2a0..1e69c0f 100644 --- a/packages/nodes/src/integrations/google-sheets/googleSheets.ts +++ b/packages/nodes/src/integrations/google-sheets/googleSheets.ts @@ -1,4 +1,4 @@ -import { defineOAuth2Credential } from '../types/credentials.js'; +import { defineOAuth2Credential } from '@jam-nodes/core'; import { z } from 'zod'; export const GoogleSheetsCredential = defineOAuth2Credential({ diff --git a/packages/nodes/src/integrations/index.ts b/packages/nodes/src/integrations/index.ts index 25707be..5409920 100644 --- a/packages/nodes/src/integrations/index.ts +++ b/packages/nodes/src/integrations/index.ts @@ -275,3 +275,44 @@ export { type SlackSearchMatch, slackCredential, } from './slack/index.js' + +// Lemlist integrations +export { + lemlistAddLeadNode, + lemlistGetCampaignsNode, + lemlistGetActivityNode, + lemlistPauseLeadNode, + lemlistResumeLeadNode, + lemlistMarkAsInterestedNode, + LemlistLeadSchema, + LemlistCampaignSchema, + LemlistActivitySchema, + LemlistAddLeadInputSchema, + LemlistAddLeadOutputSchema, + LemlistGetCampaignsInputSchema, + LemlistGetCampaignsOutputSchema, + LemlistGetActivityInputSchema, + LemlistGetActivityOutputSchema, + LemlistPauseLeadInputSchema, + LemlistPauseLeadOutputSchema, + LemlistResumeLeadInputSchema, + LemlistResumeLeadOutputSchema, + LemlistMarkAsInterestedInputSchema, + LemlistMarkAsInterestedOutputSchema, + type LemlistLead, + type LemlistCampaign, + type LemlistActivity, + type LemlistAddLeadInput, + type LemlistAddLeadOutput, + type LemlistGetCampaignsInput, + type LemlistGetCampaignsOutput, + type LemlistGetActivityInput, + type LemlistGetActivityOutput, + type LemlistPauseLeadInput, + type LemlistPauseLeadOutput, + type LemlistResumeLeadInput, + type LemlistResumeLeadOutput, + type LemlistMarkAsInterestedInput, + type LemlistMarkAsInterestedOutput, + lemlistCredential, +} from './lemlist/index.js' diff --git a/packages/nodes/src/integrations/lemlist/__tests__/lemlist.test.ts b/packages/nodes/src/integrations/lemlist/__tests__/lemlist.test.ts new file mode 100644 index 0000000..b7a2fe5 --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/__tests__/lemlist.test.ts @@ -0,0 +1,902 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + lemlistCredential, + lemlistAddLeadNode, + lemlistGetCampaignsNode, + lemlistGetActivityNode, + lemlistPauseLeadNode, + lemlistResumeLeadNode, + lemlistMarkAsInterestedNode, + LemlistAddLeadInputSchema, + LemlistGetCampaignsInputSchema, + LemlistGetActivityInputSchema, + LemlistPauseLeadInputSchema, + LemlistResumeLeadInputSchema, + LemlistMarkAsInterestedInputSchema, + buildLemlistAuthHeader, + buildLemlistHeaders, + LEMLIST_API_BASE, +} from '../index.js' + +const mockContext = { + userId: 'u1', + workflowExecutionId: 'w1', + variables: {}, + resolveNestedPath: () => undefined, + credentials: { lemlist: { apiKey: 'test-api-key' } }, +} as const + +const mockContextNoCreds = { + userId: 'u1', + workflowExecutionId: 'w1', + variables: {}, + resolveNestedPath: () => undefined, +} as const + +const mockLead = { + _id: 'lea_abc123', + email: 'jane@example.com', + firstName: 'Jane', + lastName: 'Doe', + companyName: 'Acme Corp', + jobTitle: 'Founder', + companyDomain: 'acme.com', + isPaused: false, + campaignId: 'cam_xyz789', + contactId: 'ctc_pqr456', + emailStatus: 'deliverable', +} + +const mockCampaign = { + _id: 'cam_xyz789', + name: 'Q2 Outreach', + status: 'running', + labels: ['outbound'], + createdAt: '2025-01-15T10:00:00Z', + createdBy: 'usr_me', + sequenceId: 'seq_111', + scheduleIds: ['sch_222'], + teamId: 'team_333', + hasError: false, + errors: [], +} + +const mockActivity = { + _id: 'act_aaa', + type: 'emailOpened', + leadId: 'lea_abc123', + campaignId: 'cam_xyz789', + sequenceId: 'seq_111', + sequenceStep: 0, + createdAt: '2025-02-01T09:00:00Z', +} + +function jsonResponse( + body: unknown, + status = 200, + extraHeaders: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...extraHeaders }, + }) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +// ─── Credential ────────────────────────────────────────────────────────────── + +describe('lemlist credential', () => { + it('defines credential metadata', () => { + expect(lemlistCredential.name).toBe('lemlist') + expect(lemlistCredential.displayName).toBe('Lemlist API Key') + expect(lemlistCredential.type).toBe('apiKey') + expect(lemlistCredential.authenticate.properties['Authorization']).toBe( + 'Basic {{apiKey}}', + ) + }) + + it('schema rejects empty apiKey', () => { + const result = lemlistCredential.schema.safeParse({ apiKey: '' }) + expect(result.success).toBe(false) + }) + + it('schema accepts valid apiKey', () => { + const result = lemlistCredential.schema.safeParse({ apiKey: 'abc123' }) + expect(result.success).toBe(true) + }) +}) + +// ─── Utils ─────────────────────────────────────────────────────────────────── + +describe('lemlist utils', () => { + it('buildLemlistAuthHeader encodes empty-username basic auth', () => { + const header = buildLemlistAuthHeader('abc123') + expect(header.startsWith('Basic ')).toBe(true) + const encoded = header.slice('Basic '.length) + const decoded = Buffer.from(encoded, 'base64').toString('utf-8') + expect(decoded).toBe(':abc123') + }) + + it('buildLemlistHeaders returns Authorization and Content-Type', () => { + const headers = buildLemlistHeaders('key') + expect(headers['Authorization']).toBeDefined() + expect(headers['Authorization']!.startsWith('Basic ')).toBe(true) + expect(headers['Content-Type']).toBe('application/json') + }) + + it('LEMLIST_API_BASE is the documented base', () => { + expect(LEMLIST_API_BASE).toBe('https://api.lemlist.com/api') + }) +}) + +// ─── lemlist_add_lead ──────────────────────────────────────────────────────── + +describe('lemlist_add_lead', () => { + describe('schema', () => { + it('accepts minimal input', () => { + const result = LemlistAddLeadInputSchema.safeParse({ + campaignId: 'cam_X', + email: 'jane@example.com', + }) + expect(result.success).toBe(true) + }) + + it('rejects missing campaignId', () => { + const result = LemlistAddLeadInputSchema.safeParse({ + email: 'jane@example.com', + }) + expect(result.success).toBe(false) + }) + + it('rejects invalid email', () => { + const result = LemlistAddLeadInputSchema.safeParse({ + campaignId: 'cam_X', + email: 'not-an-email', + }) + expect(result.success).toBe(false) + }) + }) + + it('fails when API key is missing', async () => { + const result = await lemlistAddLeadNode.executor( + { campaignId: 'cam_X', email: 'jane@example.com' }, + mockContextNoCreds, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('lemlist.apiKey') + } + }) + + it('sends correct POST with Basic auth and JSON body', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(mockLead, 200)) + vi.stubGlobal('fetch', fetchMock) + + const result = await lemlistAddLeadNode.executor( + { + campaignId: 'cam_xyz789', + email: 'jane@example.com', + firstName: 'Jane', + lastName: 'Doe', + }, + mockContext, + ) + + expect(result.success).toBe(true) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`${LEMLIST_API_BASE}/campaigns/cam_xyz789/leads/`) + expect(init.method).toBe('POST') + + const headers = init.headers as Record + expect(headers['Authorization']!.startsWith('Basic ')).toBe(true) + expect(headers['Content-Type']).toBe('application/json') + + const body = JSON.parse(String(init.body)) + expect(body.email).toBe('jane@example.com') + expect(body.firstName).toBe('Jane') + expect(body.lastName).toBe('Doe') + }) + + it('omits undefined fields from request body', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(mockLead, 200)) + vi.stubGlobal('fetch', fetchMock) + + await lemlistAddLeadNode.executor( + { campaignId: 'cam_xyz789', email: 'jane@example.com' }, + mockContext, + ) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(String(init.body)) + expect(Object.keys(body).sort()).toEqual(['email']) + }) + + it('returns normalized lead on 200', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse(mockLead, 200)), + ) + + const result = await lemlistAddLeadNode.executor( + { campaignId: 'cam_xyz789', email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?._id).toBe('lea_abc123') + expect(result.output?.email).toBe('jane@example.com') + expect(result.output?.campaignId).toBe('cam_xyz789') + } + }) + + it('returns error on 422 validation failure', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(new Response('Unprocessable Entity', { status: 422 })), + ) + + const result = await lemlistAddLeadNode.executor( + { campaignId: 'cam_X', email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('422') + } + }) + + it('returns error on 401 unauthorized', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Unauthorized', { status: 401 })), + ) + + const result = await lemlistAddLeadNode.executor( + { campaignId: 'cam_X', email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('401') + } + }) +}) + +// ─── lemlist_get_campaigns ─────────────────────────────────────────────────── + +describe('lemlist_get_campaigns', () => { + it('input schema rejects limit over 100', () => { + const result = LemlistGetCampaignsInputSchema.safeParse({ limit: 101 }) + expect(result.success).toBe(false) + }) + + it('fails when API key is missing', async () => { + const result = await lemlistGetCampaignsNode.executor( + {}, + mockContextNoCreds, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('lemlist.apiKey') + } + }) + + it('calls GET /campaigns with version=v2 by default', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse([mockCampaign], 200)) + vi.stubGlobal('fetch', fetchMock) + + const result = await lemlistGetCampaignsNode.executor({}, mockContext) + expect(result.success).toBe(true) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain(`${LEMLIST_API_BASE}/campaigns?`) + expect(url).toContain('version=v2') + expect(init.method).toBe('GET') + const headers = init.headers as Record + expect(headers['Authorization']!.startsWith('Basic ')).toBe(true) + }) + + it('passes limit, offset, and status query params when provided', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse([], 200)) + vi.stubGlobal('fetch', fetchMock) + + await lemlistGetCampaignsNode.executor( + { limit: 50, offset: 10, status: 'running' }, + mockContext, + ) + + const [url] = fetchMock.mock.calls[0] as [string] + expect(url).toContain('limit=50') + expect(url).toContain('offset=10') + expect(url).toContain('status=running') + }) + + it('returns normalized campaigns with count', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse([mockCampaign, mockCampaign], 200)), + ) + + const result = await lemlistGetCampaignsNode.executor({}, mockContext) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.campaigns).toHaveLength(2) + expect(result.output?.count).toBe(2) + expect(result.output?.campaigns[0]?._id).toBe('cam_xyz789') + expect(result.output?.campaigns[0]?.status).toBe('running') + } + }) + + it('returns empty array when no campaigns', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([], 200))) + const result = await lemlistGetCampaignsNode.executor({}, mockContext) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.count).toBe(0) + expect(result.output?.campaigns).toEqual([]) + } + }) + + it('returns error on non-2xx response', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(new Response('Server Error', { status: 500 })), + ) + const result = await lemlistGetCampaignsNode.executor({}, mockContext) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toMatch(/500|Server error/) + } + }) +}) + +// ─── lemlist_get_activity ──────────────────────────────────────────────────── + +describe('lemlist_get_activity', () => { + it('input schema rejects limit over 100', () => { + const result = LemlistGetActivityInputSchema.safeParse({ limit: 500 }) + expect(result.success).toBe(false) + }) + + it('fails when API key is missing', async () => { + const result = await lemlistGetActivityNode.executor({}, mockContextNoCreds) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('lemlist.apiKey') + } + }) + + it('calls GET /activities with version=v2', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse([mockActivity], 200)) + vi.stubGlobal('fetch', fetchMock) + + await lemlistGetActivityNode.executor({}, mockContext) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain(`${LEMLIST_API_BASE}/activities?`) + expect(url).toContain('version=v2') + expect(init.method).toBe('GET') + }) + + it('passes campaignId and type filters when provided', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse([], 200)) + vi.stubGlobal('fetch', fetchMock) + + await lemlistGetActivityNode.executor( + { campaignId: 'cam_xyz789', type: 'emailOpened' }, + mockContext, + ) + + const [url] = fetchMock.mock.calls[0] as [string] + expect(url).toContain('campaignId=cam_xyz789') + expect(url).toContain('type=emailOpened') + }) + + it('returns normalized activities with count', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse([mockActivity], 200)), + ) + + const result = await lemlistGetActivityNode.executor({}, mockContext) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.count).toBe(1) + expect(result.output?.activities[0]?._id).toBe('act_aaa') + expect(result.output?.activities[0]?.type).toBe('emailOpened') + } + }) + + it('handles empty activity array', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([], 200))) + const result = await lemlistGetActivityNode.executor({}, mockContext) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.count).toBe(0) + } + }) + + it('returns error on non-2xx response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Bad Request', { status: 400 })), + ) + const result = await lemlistGetActivityNode.executor({}, mockContext) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('400') + } + }) +}) + +// ─── lemlist_pause_lead ────────────────────────────────────────────────────── + +describe('lemlist_pause_lead', () => { + it('input schema requires leadId', () => { + const result = LemlistPauseLeadInputSchema.safeParse({}) + expect(result.success).toBe(false) + }) + + it('fails when API key is missing', async () => { + const result = await lemlistPauseLeadNode.executor( + { leadId: 'lea_abc123' }, + mockContextNoCreds, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('lemlist.apiKey') + } + }) + + it('sends POST to /leads/pause/{leadId} without campaignId', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + jsonResponse([{ ...mockLead, isPaused: true }], 200), + ) + vi.stubGlobal('fetch', fetchMock) + + await lemlistPauseLeadNode.executor( + { leadId: 'lea_abc123' }, + mockContext, + ) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`${LEMLIST_API_BASE}/leads/pause/lea_abc123`) + expect(url).not.toContain('campaignId=') + expect(init.method).toBe('POST') + }) + + it('sends POST with campaignId query param when provided', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + jsonResponse([{ ...mockLead, isPaused: true }], 200), + ) + vi.stubGlobal('fetch', fetchMock) + + await lemlistPauseLeadNode.executor( + { leadId: 'lea_abc123', campaignId: 'cam_xyz789' }, + mockContext, + ) + + const [url] = fetchMock.mock.calls[0] as [string] + expect(url).toContain('/leads/pause/lea_abc123') + expect(url).toContain('campaignId=cam_xyz789') + }) + + it('returns normalized leads array with isPaused true', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse([{ ...mockLead, isPaused: true }], 200), + ), + ) + + const result = await lemlistPauseLeadNode.executor( + { leadId: 'lea_abc123' }, + mockContext, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.leads).toHaveLength(1) + expect(result.output?.leads[0]?.isPaused).toBe(true) + } + }) + + it('URL-encodes special characters in leadId', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse([mockLead], 200)) + vi.stubGlobal('fetch', fetchMock) + + await lemlistPauseLeadNode.executor( + { leadId: 'lea+tag/abc' }, + mockContext, + ) + + const [url] = fetchMock.mock.calls[0] as [string] + expect(url).toContain('lea%2Btag%2Fabc') + expect(url).not.toContain('lea+tag/abc') + }) + + it('returns error on 404 lead not found', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 })), + ) + + const result = await lemlistPauseLeadNode.executor( + { leadId: 'missing' }, + mockContext, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('404') + } + }) +}) + +// ─── lemlist_resume_lead ───────────────────────────────────────────────────── + +describe('lemlist_resume_lead', () => { + it('input schema requires leadId', () => { + const result = LemlistResumeLeadInputSchema.safeParse({}) + expect(result.success).toBe(false) + }) + + it('fails when API key is missing', async () => { + const result = await lemlistResumeLeadNode.executor( + { leadId: 'lea_abc123' }, + mockContextNoCreds, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('lemlist.apiKey') + } + }) + + it('sends POST to /leads/start/{leadId}', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + jsonResponse([{ ...mockLead, isPaused: false }], 200), + ) + vi.stubGlobal('fetch', fetchMock) + + await lemlistResumeLeadNode.executor( + { leadId: 'lea_abc123' }, + mockContext, + ) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`${LEMLIST_API_BASE}/leads/start/lea_abc123`) + expect(init.method).toBe('POST') + }) + + it('sends campaignId query param when provided', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + jsonResponse([{ ...mockLead, isPaused: false }], 200), + ) + vi.stubGlobal('fetch', fetchMock) + + await lemlistResumeLeadNode.executor( + { leadId: 'lea_abc123', campaignId: 'cam_xyz789' }, + mockContext, + ) + + const [url] = fetchMock.mock.calls[0] as [string] + expect(url).toContain('/leads/start/lea_abc123') + expect(url).toContain('campaignId=cam_xyz789') + }) + + it('returns normalized leads with isPaused false', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse([{ ...mockLead, isPaused: false }], 200), + ), + ) + + const result = await lemlistResumeLeadNode.executor( + { leadId: 'lea_abc123' }, + mockContext, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.leads[0]?.isPaused).toBe(false) + } + }) + + it('URL-encodes special characters in leadId', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse([mockLead], 200)) + vi.stubGlobal('fetch', fetchMock) + + await lemlistResumeLeadNode.executor( + { leadId: 'lea+tag/abc' }, + mockContext, + ) + + const [url] = fetchMock.mock.calls[0] as [string] + expect(url).toContain('lea%2Btag%2Fabc') + expect(url).not.toContain('lea+tag/abc') + }) + + it('returns error on 404', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 })), + ) + + const result = await lemlistResumeLeadNode.executor( + { leadId: 'missing' }, + mockContext, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('404') + } + }) +}) + +// ─── lemlist_mark_as_interested ────────────────────────────────────────────── + +describe('lemlist_mark_as_interested', () => { + it('input schema requires both campaignId and leadIdOrEmail', () => { + expect( + LemlistMarkAsInterestedInputSchema.safeParse({ campaignId: 'cam_X' }) + .success, + ).toBe(false) + expect( + LemlistMarkAsInterestedInputSchema.safeParse({ + leadIdOrEmail: 'jane@example.com', + }).success, + ).toBe(false) + }) + + it('fails when API key is missing', async () => { + const result = await lemlistMarkAsInterestedNode.executor( + { campaignId: 'cam_X', leadIdOrEmail: 'lea_abc123' }, + mockContextNoCreds, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('lemlist.apiKey') + } + }) + + it('sends POST to /campaigns/{id}/leads/{leadIdOrEmail}/interested', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(mockLead, 200)) + vi.stubGlobal('fetch', fetchMock) + + await lemlistMarkAsInterestedNode.executor( + { campaignId: 'cam_xyz789', leadIdOrEmail: 'lea_abc123' }, + mockContext, + ) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe( + `${LEMLIST_API_BASE}/campaigns/cam_xyz789/leads/lea_abc123/interested`, + ) + expect(init.method).toBe('POST') + }) + + it('URL-encodes email lead identifier', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(mockLead, 200)) + vi.stubGlobal('fetch', fetchMock) + + await lemlistMarkAsInterestedNode.executor( + { campaignId: 'cam_xyz789', leadIdOrEmail: 'support@lemlist.com' }, + mockContext, + ) + + const [url] = fetchMock.mock.calls[0] as [string] + expect(url).toContain('support%40lemlist.com') + expect(url).toContain('/interested') + }) + + it('returns single normalized lead on 200', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse(mockLead, 200)), + ) + + const result = await lemlistMarkAsInterestedNode.executor( + { campaignId: 'cam_xyz789', leadIdOrEmail: 'lea_abc123' }, + mockContext, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?._id).toBe('lea_abc123') + expect(result.output?.email).toBe('jane@example.com') + } + }) + + it('returns error on 404 campaign/lead not found', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 })), + ) + + const result = await lemlistMarkAsInterestedNode.executor( + { campaignId: 'cam_X', leadIdOrEmail: 'missing' }, + mockContext, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('404') + } + }) + + it('returns error on 405 method not allowed', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(new Response('Method Not Allowed', { status: 405 })), + ) + + const result = await lemlistMarkAsInterestedNode.executor( + { campaignId: 'cam_X', leadIdOrEmail: 'lea_X' }, + mockContext, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('405') + } + }) +}) + +// ─── Package-level exports ──────────────────────────────────────────────────── + +describe('lemlist package exports', () => { + it('exports all six lemlist nodes from package root', async () => { + const pkg = await import('../../../index.js') + expect(pkg.lemlistAddLeadNode.type).toBe('lemlist_add_lead') + expect(pkg.lemlistGetCampaignsNode.type).toBe('lemlist_get_campaigns') + expect(pkg.lemlistGetActivityNode.type).toBe('lemlist_get_activity') + expect(pkg.lemlistPauseLeadNode.type).toBe('lemlist_pause_lead') + expect(pkg.lemlistResumeLeadNode.type).toBe('lemlist_resume_lead') + expect(pkg.lemlistMarkAsInterestedNode.type).toBe( + 'lemlist_mark_as_interested', + ) + }) + + it('exports lemlistCredential from package root', async () => { + const pkg = await import('../../../index.js') + expect(pkg.lemlistCredential.name).toBe('lemlist') + expect(pkg.lemlistCredential.type).toBe('apiKey') + }) + + it('builtInNodes includes all six lemlist nodes', async () => { + const pkg = await import('../../../index.js') + const lemlistNodes = pkg.builtInNodes.filter((n) => + n.type.startsWith('lemlist_'), + ) + expect(lemlistNodes).toHaveLength(6) + }) +}) + +// ─── Integration tests (cross-node assertions) ─────────────────────────────── + +describe('lemlist integration — end-to-end', () => { + const allNodes = [ + { + node: lemlistAddLeadNode, + input: { campaignId: 'cam_X', email: 'jane@example.com' }, + successResponse: jsonResponse(mockLead, 200), + }, + { + node: lemlistGetCampaignsNode, + input: {}, + successResponse: jsonResponse([mockCampaign], 200), + }, + { + node: lemlistGetActivityNode, + input: {}, + successResponse: jsonResponse([mockActivity], 200), + }, + { + node: lemlistPauseLeadNode, + input: { leadId: 'lea_abc123' }, + successResponse: jsonResponse([mockLead], 200), + }, + { + node: lemlistResumeLeadNode, + input: { leadId: 'lea_abc123' }, + successResponse: jsonResponse([mockLead], 200), + }, + { + node: lemlistMarkAsInterestedNode, + input: { campaignId: 'cam_X', leadIdOrEmail: 'lea_abc123' }, + successResponse: jsonResponse(mockLead, 200), + }, + ] + + it('every node has integration category, supportsRerun, non-empty description, and lemlist_ type', () => { + for (const { node } of allNodes) { + expect(node.category).toBe('integration') + expect(node.capabilities?.supportsRerun).toBe(true) + expect(node.description.length).toBeGreaterThan(0) + expect(node.type.startsWith('lemlist_')).toBe(true) + } + const types = allNodes.map((n) => n.node.type) + expect(new Set(types).size).toBe(6) + }) + + it('every node fails identically when API key is missing', async () => { + for (const { node, input } of allNodes) { + const result = await node.executor( + input as never, + mockContextNoCreds, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('lemlist.apiKey') + } + } + }) + + it('every node sends a Basic Authorization header derived from the API key', async () => { + for (const { node, input, successResponse } of allNodes) { + const fetchMock = vi.fn().mockResolvedValue(successResponse.clone()) + vi.stubGlobal('fetch', fetchMock) + + await node.executor(input as never, mockContext) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const headers = init.headers as Record + expect(headers['Authorization']).toBeDefined() + expect(headers['Authorization']!.startsWith('Basic ')).toBe(true) + const encoded = headers['Authorization']!.slice('Basic '.length) + const decoded = Buffer.from(encoded, 'base64').toString('utf-8') + expect(decoded).toBe(':test-api-key') + + vi.unstubAllGlobals() + } + }) + + it('every node retries on 429 then succeeds (proves fetchWithRetry is wired)', async () => { + for (const { node, input, successResponse } of allNodes) { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response('rate limited', { + status: 429, + headers: { 'retry-after': '0' }, + }), + ) + .mockResolvedValueOnce(successResponse.clone()) + + vi.stubGlobal('fetch', fetchMock) + + const result = await node.executor(input as never, mockContext) + expect(result.success).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(2) + + vi.unstubAllGlobals() + } + }) +}) diff --git a/packages/nodes/src/integrations/lemlist/add-lead.ts b/packages/nodes/src/integrations/lemlist/add-lead.ts new file mode 100644 index 0000000..8cc24ea --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/add-lead.ts @@ -0,0 +1,81 @@ +import { defineNode } from '@jam-nodes/core' +import { fetchWithRetry } from '../../utils/http.js' +import { LEMLIST_API_BASE, buildLemlistHeaders } from './utils.js' +import { + LemlistAddLeadInputSchema, + LemlistAddLeadOutputSchema, + LemlistLeadSchema, + type LemlistAddLeadInput, + type LemlistLead, +} from './schemas.js' + +export const lemlistAddLeadNode = defineNode({ + type: 'lemlist_add_lead', + name: 'Lemlist Add Lead', + description: 'Add a new lead to a Lemlist campaign', + category: 'integration', + inputSchema: LemlistAddLeadInputSchema, + outputSchema: LemlistAddLeadOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + executor: async (input: LemlistAddLeadInput, context) => { + try { + const apiKey = context.credentials?.lemlist?.apiKey + if (!apiKey) { + return { + success: false, + error: + 'Lemlist API key not configured. Please provide context.credentials.lemlist.apiKey.', + } + } + + const body: Record = { email: input.email } + if (input.firstName !== undefined) body['firstName'] = input.firstName + if (input.lastName !== undefined) body['lastName'] = input.lastName + if (input.companyName !== undefined) + body['companyName'] = input.companyName + if (input.jobTitle !== undefined) body['jobTitle'] = input.jobTitle + if (input.linkedinUrl !== undefined) + body['linkedinUrl'] = input.linkedinUrl + if (input.phone !== undefined) body['phone'] = input.phone + if (input.companyDomain !== undefined) + body['companyDomain'] = input.companyDomain + if (input.icebreaker !== undefined) body['icebreaker'] = input.icebreaker + if (input.timezone !== undefined) body['timezone'] = input.timezone + if (input.contactOwner !== undefined) + body['contactOwner'] = input.contactOwner + if (input.picture !== undefined) body['picture'] = input.picture + + const response = await fetchWithRetry( + `${LEMLIST_API_BASE}/campaigns/${encodeURIComponent(input.campaignId)}/leads/`, + { + method: 'POST', + headers: buildLemlistHeaders(apiKey), + body: JSON.stringify(body), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }, + ) + + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Lemlist API error ${response.status}: ${errorText}`, + } + } + + const data = (await response.json()) as LemlistLead + return { + success: true, + output: LemlistLeadSchema.parse(data), + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + }, +}) diff --git a/packages/nodes/src/integrations/lemlist/credentials.ts b/packages/nodes/src/integrations/lemlist/credentials.ts new file mode 100644 index 0000000..3d82db6 --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/credentials.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' +import { defineApiKeyCredential } from '@jam-nodes/core' + +export const lemlistCredential = defineApiKeyCredential({ + name: 'lemlist', + displayName: 'Lemlist API Key', + documentationUrl: 'https://developer.lemlist.com/', + schema: z.object({ + apiKey: z.string().min(1), + }), + authenticate: { + type: 'header', + properties: { + Authorization: 'Basic {{apiKey}}', + }, + }, +}) diff --git a/packages/nodes/src/integrations/lemlist/get-activity.ts b/packages/nodes/src/integrations/lemlist/get-activity.ts new file mode 100644 index 0000000..338d378 --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/get-activity.ts @@ -0,0 +1,79 @@ +import { defineNode } from '@jam-nodes/core' +import { fetchWithRetry } from '../../utils/http.js' +import { LEMLIST_API_BASE, buildLemlistHeaders } from './utils.js' +import { + LemlistGetActivityInputSchema, + LemlistGetActivityOutputSchema, + LemlistActivitySchema, + type LemlistGetActivityInput, + type LemlistActivity, +} from './schemas.js' + +export const lemlistGetActivityNode = defineNode({ + type: 'lemlist_get_activity', + name: 'Lemlist Get Activity', + description: + 'Retrieve Lemlist campaign activity events (emails opened, clicked, replied, paused, etc.)', + category: 'integration', + inputSchema: LemlistGetActivityInputSchema, + outputSchema: LemlistGetActivityOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + executor: async (input: LemlistGetActivityInput, context) => { + try { + const apiKey = context.credentials?.lemlist?.apiKey + if (!apiKey) { + return { + success: false, + error: + 'Lemlist API key not configured. Please provide context.credentials.lemlist.apiKey.', + } + } + + const params = new URLSearchParams({ version: 'v2' }) + if (input.campaignId !== undefined) + params.set('campaignId', input.campaignId) + if (input.leadId !== undefined) params.set('leadId', input.leadId) + if (input.type !== undefined) params.set('type', input.type) + if (input.isFirst !== undefined) + params.set('isFirst', String(input.isFirst)) + if (input.limit !== undefined) params.set('limit', String(input.limit)) + if (input.offset !== undefined) params.set('offset', String(input.offset)) + + const response = await fetchWithRetry( + `${LEMLIST_API_BASE}/activities?${params.toString()}`, + { + method: 'GET', + headers: buildLemlistHeaders(apiKey), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }, + ) + + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Lemlist API error ${response.status}: ${errorText}`, + } + } + + const data = (await response.json()) as LemlistActivity[] + const activities = data.map((a) => LemlistActivitySchema.parse(a)) + + return { + success: true, + output: { + activities, + count: activities.length, + }, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + }, +}) diff --git a/packages/nodes/src/integrations/lemlist/get-campaigns.ts b/packages/nodes/src/integrations/lemlist/get-campaigns.ts new file mode 100644 index 0000000..04f44b1 --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/get-campaigns.ts @@ -0,0 +1,77 @@ +import { defineNode } from '@jam-nodes/core' +import { fetchWithRetry } from '../../utils/http.js' +import { LEMLIST_API_BASE, buildLemlistHeaders } from './utils.js' +import { + LemlistGetCampaignsInputSchema, + LemlistGetCampaignsOutputSchema, + LemlistCampaignSchema, + type LemlistGetCampaignsInput, + type LemlistCampaign, +} from './schemas.js' + +export const lemlistGetCampaignsNode = defineNode({ + type: 'lemlist_get_campaigns', + name: 'Lemlist Get Campaigns', + description: 'List all Lemlist campaigns for the authenticated team', + category: 'integration', + inputSchema: LemlistGetCampaignsInputSchema, + outputSchema: LemlistGetCampaignsOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + executor: async (input: LemlistGetCampaignsInput, context) => { + try { + const apiKey = context.credentials?.lemlist?.apiKey + if (!apiKey) { + return { + success: false, + error: + 'Lemlist API key not configured. Please provide context.credentials.lemlist.apiKey.', + } + } + + const params = new URLSearchParams({ version: 'v2' }) + if (input.limit !== undefined) params.set('limit', String(input.limit)) + if (input.offset !== undefined) params.set('offset', String(input.offset)) + if (input.page !== undefined) params.set('page', String(input.page)) + if (input.status !== undefined) params.set('status', input.status) + if (input.sortBy !== undefined) params.set('sortBy', input.sortBy) + if (input.sortOrder !== undefined) + params.set('sortOrder', input.sortOrder) + + const response = await fetchWithRetry( + `${LEMLIST_API_BASE}/campaigns?${params.toString()}`, + { + method: 'GET', + headers: buildLemlistHeaders(apiKey), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }, + ) + + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Lemlist API error ${response.status}: ${errorText}`, + } + } + + const data = (await response.json()) as LemlistCampaign[] + const campaigns = data.map((c) => LemlistCampaignSchema.parse(c)) + + return { + success: true, + output: { + campaigns, + count: campaigns.length, + }, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + }, +}) diff --git a/packages/nodes/src/integrations/lemlist/index.ts b/packages/nodes/src/integrations/lemlist/index.ts new file mode 100644 index 0000000..3d22df8 --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/index.ts @@ -0,0 +1,47 @@ +export { lemlistCredential } from './credentials.js' + +export { + LEMLIST_API_BASE, + buildLemlistAuthHeader, + buildLemlistHeaders, +} from './utils.js' + +export { + LemlistLeadSchema, + LemlistCampaignSchema, + LemlistActivitySchema, + LemlistAddLeadInputSchema, + LemlistAddLeadOutputSchema, + LemlistGetCampaignsInputSchema, + LemlistGetCampaignsOutputSchema, + LemlistGetActivityInputSchema, + LemlistGetActivityOutputSchema, + LemlistPauseLeadInputSchema, + LemlistPauseLeadOutputSchema, + LemlistResumeLeadInputSchema, + LemlistResumeLeadOutputSchema, + LemlistMarkAsInterestedInputSchema, + LemlistMarkAsInterestedOutputSchema, + type LemlistLead, + type LemlistCampaign, + type LemlistActivity, + type LemlistAddLeadInput, + type LemlistAddLeadOutput, + type LemlistGetCampaignsInput, + type LemlistGetCampaignsOutput, + type LemlistGetActivityInput, + type LemlistGetActivityOutput, + type LemlistPauseLeadInput, + type LemlistPauseLeadOutput, + type LemlistResumeLeadInput, + type LemlistResumeLeadOutput, + type LemlistMarkAsInterestedInput, + type LemlistMarkAsInterestedOutput, +} from './schemas.js' + +export { lemlistAddLeadNode } from './add-lead.js' +export { lemlistGetCampaignsNode } from './get-campaigns.js' +export { lemlistGetActivityNode } from './get-activity.js' +export { lemlistPauseLeadNode } from './pause-lead.js' +export { lemlistResumeLeadNode } from './resume-lead.js' +export { lemlistMarkAsInterestedNode } from './mark-as-interested.js' diff --git a/packages/nodes/src/integrations/lemlist/mark-as-interested.ts b/packages/nodes/src/integrations/lemlist/mark-as-interested.ts new file mode 100644 index 0000000..51407fc --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/mark-as-interested.ts @@ -0,0 +1,67 @@ +import { defineNode } from '@jam-nodes/core' +import { fetchWithRetry } from '../../utils/http.js' +import { LEMLIST_API_BASE, buildLemlistHeaders } from './utils.js' +import { + LemlistMarkAsInterestedInputSchema, + LemlistMarkAsInterestedOutputSchema, + LemlistLeadSchema, + type LemlistMarkAsInterestedInput, + type LemlistLead, +} from './schemas.js' + +export const lemlistMarkAsInterestedNode = defineNode({ + type: 'lemlist_mark_as_interested', + name: 'Lemlist Mark Lead as Interested', + description: 'Mark a Lemlist lead as interested in a specific campaign', + category: 'integration', + inputSchema: LemlistMarkAsInterestedInputSchema, + outputSchema: LemlistMarkAsInterestedOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + executor: async (input: LemlistMarkAsInterestedInput, context) => { + try { + const apiKey = context.credentials?.lemlist?.apiKey + if (!apiKey) { + return { + success: false, + error: + 'Lemlist API key not configured. Please provide context.credentials.lemlist.apiKey.', + } + } + + const campaignId = encodeURIComponent(input.campaignId) + const leadIdOrEmail = encodeURIComponent(input.leadIdOrEmail) + const url = `${LEMLIST_API_BASE}/campaigns/${campaignId}/leads/${leadIdOrEmail}/interested` + + const response = await fetchWithRetry( + url, + { + method: 'POST', + headers: buildLemlistHeaders(apiKey), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }, + ) + + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Lemlist API error ${response.status}: ${errorText}`, + } + } + + const data = (await response.json()) as LemlistLead + return { + success: true, + output: LemlistLeadSchema.parse(data), + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + }, +}) diff --git a/packages/nodes/src/integrations/lemlist/pause-lead.ts b/packages/nodes/src/integrations/lemlist/pause-lead.ts new file mode 100644 index 0000000..b25fd4d --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/pause-lead.ts @@ -0,0 +1,73 @@ +import { defineNode } from '@jam-nodes/core' +import { fetchWithRetry } from '../../utils/http.js' +import { LEMLIST_API_BASE, buildLemlistHeaders } from './utils.js' +import { + LemlistPauseLeadInputSchema, + LemlistPauseLeadOutputSchema, + LemlistLeadSchema, + type LemlistPauseLeadInput, + type LemlistLead, +} from './schemas.js' + +export const lemlistPauseLeadNode = defineNode({ + type: 'lemlist_pause_lead', + name: 'Lemlist Pause Lead', + description: + 'Pause outreach to a specific Lemlist lead in all campaigns or a single campaign', + category: 'integration', + inputSchema: LemlistPauseLeadInputSchema, + outputSchema: LemlistPauseLeadOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + executor: async (input: LemlistPauseLeadInput, context) => { + try { + const apiKey = context.credentials?.lemlist?.apiKey + if (!apiKey) { + return { + success: false, + error: + 'Lemlist API key not configured. Please provide context.credentials.lemlist.apiKey.', + } + } + + const leadId = encodeURIComponent(input.leadId) + let url = `${LEMLIST_API_BASE}/leads/pause/${leadId}` + if (input.campaignId !== undefined) { + const params = new URLSearchParams({ campaignId: input.campaignId }) + url += `?${params.toString()}` + } + + const response = await fetchWithRetry( + url, + { + method: 'POST', + headers: buildLemlistHeaders(apiKey), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }, + ) + + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Lemlist API error ${response.status}: ${errorText}`, + } + } + + const data = (await response.json()) as LemlistLead[] + const leads = data.map((l) => LemlistLeadSchema.parse(l)) + + return { + success: true, + output: { leads }, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + }, +}) diff --git a/packages/nodes/src/integrations/lemlist/resume-lead.ts b/packages/nodes/src/integrations/lemlist/resume-lead.ts new file mode 100644 index 0000000..db010a6 --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/resume-lead.ts @@ -0,0 +1,73 @@ +import { defineNode } from '@jam-nodes/core' +import { fetchWithRetry } from '../../utils/http.js' +import { LEMLIST_API_BASE, buildLemlistHeaders } from './utils.js' +import { + LemlistResumeLeadInputSchema, + LemlistResumeLeadOutputSchema, + LemlistLeadSchema, + type LemlistResumeLeadInput, + type LemlistLead, +} from './schemas.js' + +export const lemlistResumeLeadNode = defineNode({ + type: 'lemlist_resume_lead', + name: 'Lemlist Resume Lead', + description: + 'Resume outreach to a paused Lemlist lead in all campaigns or a single campaign', + category: 'integration', + inputSchema: LemlistResumeLeadInputSchema, + outputSchema: LemlistResumeLeadOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + executor: async (input: LemlistResumeLeadInput, context) => { + try { + const apiKey = context.credentials?.lemlist?.apiKey + if (!apiKey) { + return { + success: false, + error: + 'Lemlist API key not configured. Please provide context.credentials.lemlist.apiKey.', + } + } + + const leadId = encodeURIComponent(input.leadId) + let url = `${LEMLIST_API_BASE}/leads/start/${leadId}` + if (input.campaignId !== undefined) { + const params = new URLSearchParams({ campaignId: input.campaignId }) + url += `?${params.toString()}` + } + + const response = await fetchWithRetry( + url, + { + method: 'POST', + headers: buildLemlistHeaders(apiKey), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }, + ) + + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Lemlist API error ${response.status}: ${errorText}`, + } + } + + const data = (await response.json()) as LemlistLead[] + const leads = data.map((l) => LemlistLeadSchema.parse(l)) + + return { + success: true, + output: { leads }, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + }, +}) diff --git a/packages/nodes/src/integrations/lemlist/schemas.ts b/packages/nodes/src/integrations/lemlist/schemas.ts new file mode 100644 index 0000000..d71fe33 --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/schemas.ts @@ -0,0 +1,180 @@ +import { z } from 'zod' + +// ─── Shared schemas ────────────────────────────────────────────────────────── + +/** + * Normalized shape for a Lemlist lead, used as the output of add/pause/resume/mark-as-interested. + * Fields are `.nullable().optional()` because the Lemlist API omits empty fields and + * adds new fields over time — we model the documented ones and stay permissive. + */ +export const LemlistLeadSchema = z.object({ + _id: z.string(), + email: z.string(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + companyName: z.string().nullable().optional(), + jobTitle: z.string().nullable().optional(), + companyDomain: z.string().nullable().optional(), + preferredContactMethod: z.string().nullable().optional(), + industry: z.string().nullable().optional(), + isPaused: z.boolean().nullable().optional(), + campaignId: z.string().nullable().optional(), + campaignName: z.string().nullable().optional(), + contactId: z.string().nullable().optional(), + emailStatus: z.string().nullable().optional(), +}) + +/** + * Normalized shape for a Lemlist campaign from GET /campaigns. + */ +export const LemlistCampaignSchema = z.object({ + _id: z.string(), + name: z.string(), + status: z.string().nullable().optional(), + labels: z.array(z.string()).nullable().optional(), + createdAt: z.string().nullable().optional(), + createdBy: z.string().nullable().optional(), + sequenceId: z.string().nullable().optional(), + scheduleIds: z.array(z.string()).nullable().optional(), + teamId: z.string().nullable().optional(), + hasError: z.boolean().nullable().optional(), + errors: z.array(z.string()).nullable().optional(), +}) + +/** + * Normalized shape for a Lemlist activity from GET /activities. + */ +export const LemlistActivitySchema = z.object({ + _id: z.string(), + type: z.string(), + leadId: z.string().nullable().optional(), + campaignId: z.string().nullable().optional(), + sequenceId: z.string().nullable().optional(), + sequenceStep: z.number().nullable().optional(), + createdAt: z.string().nullable().optional(), +}) + +export type LemlistLead = z.infer +export type LemlistCampaign = z.infer +export type LemlistActivity = z.infer + +// ─── lemlistAddLead ────────────────────────────────────────────────────────── + +export const LemlistAddLeadInputSchema = z.object({ + campaignId: z.string().min(1), + email: z.string().email(), + firstName: z.string().optional(), + lastName: z.string().optional(), + companyName: z.string().optional(), + jobTitle: z.string().optional(), + linkedinUrl: z.string().optional(), + phone: z.string().optional(), + companyDomain: z.string().optional(), + icebreaker: z.string().optional(), + timezone: z.string().optional(), + contactOwner: z.string().optional(), + picture: z.string().optional(), +}) + +export const LemlistAddLeadOutputSchema = LemlistLeadSchema + +export type LemlistAddLeadInput = z.infer +export type LemlistAddLeadOutput = z.infer + +// ─── lemlistGetCampaigns ───────────────────────────────────────────────────── + +export const LemlistGetCampaignsInputSchema = z.object({ + limit: z.number().int().positive().max(100).optional(), + offset: z.number().int().nonnegative().optional(), + page: z.number().int().positive().optional(), + status: z + .enum(['running', 'paused', 'draft', 'ended', 'archived', 'errors']) + .optional(), + sortBy: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), +}) + +export const LemlistGetCampaignsOutputSchema = z.object({ + campaigns: z.array(LemlistCampaignSchema), + count: z.number(), +}) + +export type LemlistGetCampaignsInput = z.infer< + typeof LemlistGetCampaignsInputSchema +> +export type LemlistGetCampaignsOutput = z.infer< + typeof LemlistGetCampaignsOutputSchema +> + +// ─── lemlistGetActivity ────────────────────────────────────────────────────── + +export const LemlistGetActivityInputSchema = z.object({ + campaignId: z.string().optional(), + leadId: z.string().optional(), + type: z.string().optional(), + isFirst: z.boolean().optional(), + limit: z.number().int().positive().max(100).optional(), + offset: z.number().int().nonnegative().optional(), +}) + +export const LemlistGetActivityOutputSchema = z.object({ + activities: z.array(LemlistActivitySchema), + count: z.number(), +}) + +export type LemlistGetActivityInput = z.infer< + typeof LemlistGetActivityInputSchema +> +export type LemlistGetActivityOutput = z.infer< + typeof LemlistGetActivityOutputSchema +> + +// ─── lemlistPauseLead ──────────────────────────────────────────────────────── + +export const LemlistPauseLeadInputSchema = z.object({ + leadId: z.string().min(1), + campaignId: z.string().optional(), +}) + +export const LemlistPauseLeadOutputSchema = z.object({ + leads: z.array(LemlistLeadSchema), +}) + +export type LemlistPauseLeadInput = z.infer +export type LemlistPauseLeadOutput = z.infer< + typeof LemlistPauseLeadOutputSchema +> + +// ─── lemlistResumeLead ─────────────────────────────────────────────────────── + +export const LemlistResumeLeadInputSchema = z.object({ + leadId: z.string().min(1), + campaignId: z.string().optional(), +}) + +export const LemlistResumeLeadOutputSchema = z.object({ + leads: z.array(LemlistLeadSchema), +}) + +export type LemlistResumeLeadInput = z.infer< + typeof LemlistResumeLeadInputSchema +> +export type LemlistResumeLeadOutput = z.infer< + typeof LemlistResumeLeadOutputSchema +> + +// ─── lemlistMarkAsInterested ───────────────────────────────────────────────── + +export const LemlistMarkAsInterestedInputSchema = z.object({ + campaignId: z.string().min(1), + leadIdOrEmail: z.string().min(1), +}) + +export const LemlistMarkAsInterestedOutputSchema = LemlistLeadSchema + +export type LemlistMarkAsInterestedInput = z.infer< + typeof LemlistMarkAsInterestedInputSchema +> +export type LemlistMarkAsInterestedOutput = z.infer< + typeof LemlistMarkAsInterestedOutputSchema +> diff --git a/packages/nodes/src/integrations/lemlist/utils.ts b/packages/nodes/src/integrations/lemlist/utils.ts new file mode 100644 index 0000000..78e1052 --- /dev/null +++ b/packages/nodes/src/integrations/lemlist/utils.ts @@ -0,0 +1,21 @@ +export const LEMLIST_API_BASE = 'https://api.lemlist.com/api' + +/** + * Build the Lemlist Authorization header value. + * Lemlist uses HTTP Basic auth with an empty username and the API key as the password: + * `Basic base64(":" + apiKey)`. + */ +export function buildLemlistAuthHeader(apiKey: string): string { + const encoded = Buffer.from(`:${apiKey}`).toString('base64') + return `Basic ${encoded}` +} + +/** + * Build the standard Lemlist request headers (Authorization + Content-Type). + */ +export function buildLemlistHeaders(apiKey: string): Record { + return { + Authorization: buildLemlistAuthHeader(apiKey), + 'Content-Type': 'application/json', + } +}