From 235ab96a672808816baf7457e9f78fa9aeb65346 Mon Sep 17 00:00:00 2001 From: Jack <72348727+Jack-GitHub12@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:45:56 -0500 Subject: [PATCH] feat(instantly): add Instantly.ai cold email integration (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new integration with Bearer API-key credential and three nodes following the established apify/slack pattern: - instantly_add_lead — add a lead to a campaign - instantly_create_campaign — create a campaign with a drip sequence - instantly_get_analytics — fetch per-campaign metrics Nodes stay pure per CLAUDE.md: retry/timeout handled by fetchWithRetry config, no wrapper nodes, no z.any(). When the API returns a body with `success: false`, the node surfaces that as a failure instead of silently returning an empty id. Error response text is truncated to 500 chars to avoid unbounded error payloads. Schema size caps on customVariables (50 keys), emailAccounts (50), and sequence (100 steps) bound request payloads. - packages/core: register 'instantly' in NodeCredentials - packages/nodes/src/integrations/instantly: schemas, credential, 3 node files, barrel, and a 33-test suite - packages/nodes/src/integrations/index.ts + src/index.ts: wire exports Tests: 33 passing. Full suite: 224/228 (baseline 191/195; +33, 0 regressions). Typecheck: 324 pre-existing errors in transform/* unchanged by this PR. Closes #19 --- packages/core/src/types/node.ts | 4 + packages/nodes/src/index.ts | 20 + packages/nodes/src/integrations/index.ts | 22 + .../src/integrations/instantly/credentials.ts | 17 + .../nodes/src/integrations/instantly/index.ts | 30 + .../instantly/instantly-add-lead.ts | 106 ++++ .../instantly/instantly-create-campaign.ts | 103 ++++ .../instantly/instantly-get-analytics.ts | 91 +++ .../integrations/instantly/instantly.test.ts | 538 ++++++++++++++++++ .../src/integrations/instantly/schemas.ts | 97 ++++ 10 files changed, 1028 insertions(+) create mode 100644 packages/nodes/src/integrations/instantly/credentials.ts create mode 100644 packages/nodes/src/integrations/instantly/index.ts create mode 100644 packages/nodes/src/integrations/instantly/instantly-add-lead.ts create mode 100644 packages/nodes/src/integrations/instantly/instantly-create-campaign.ts create mode 100644 packages/nodes/src/integrations/instantly/instantly-get-analytics.ts create mode 100644 packages/nodes/src/integrations/instantly/instantly.test.ts create mode 100644 packages/nodes/src/integrations/instantly/schemas.ts diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts index 45327ef..056cec6 100644 --- a/packages/core/src/types/node.ts +++ b/packages/core/src/types/node.ts @@ -76,6 +76,10 @@ export interface NodeCredentials { apify?: { apiToken: string } + /** Instantly.ai API credentials */ + instantly?: { + apiKey: string + } /** Google Sheets OAuth2 credentials */ googleSheets?: { clientId: string diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 97e62dc..f515765 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -183,6 +183,18 @@ export { WordPressPostSchema, WordPressMediaSchema, WordPressCredential, + // Instantly + instantlyAddLeadNode, + InstantlyAddLeadInputSchema, + InstantlyAddLeadOutputSchema, + instantlyCreateCampaignNode, + InstantlyCreateCampaignInputSchema, + InstantlyCreateCampaignOutputSchema, + instantlyGetAnalyticsNode, + InstantlyGetAnalyticsInputSchema, + InstantlyGetAnalyticsOutputSchema, + SequenceStepSchema, + instantlyCredential, // Google Sheets googleSheetsAppendNode, googleSheetsClearNode, @@ -267,6 +279,14 @@ export type { WordPressMedia, WordPressUploadMediaInput, WordPressUploadMediaOutput, + // Instantly + InstantlyAddLeadInput, + InstantlyAddLeadOutput, + InstantlyCreateCampaignInput, + InstantlyCreateCampaignOutput, + InstantlyGetAnalyticsInput, + InstantlyGetAnalyticsOutput, + SequenceStep, // Google Sheets AppendInput, AppendOutput, diff --git a/packages/nodes/src/integrations/index.ts b/packages/nodes/src/integrations/index.ts index 25707be..25ffcd9 100644 --- a/packages/nodes/src/integrations/index.ts +++ b/packages/nodes/src/integrations/index.ts @@ -219,6 +219,28 @@ export { apifyCredential, } from './apify/index.js' +// Instantly integrations +export { + instantlyAddLeadNode, + InstantlyAddLeadInputSchema, + InstantlyAddLeadOutputSchema, + type InstantlyAddLeadInput, + type InstantlyAddLeadOutput, + instantlyCreateCampaignNode, + InstantlyCreateCampaignInputSchema, + InstantlyCreateCampaignOutputSchema, + type InstantlyCreateCampaignInput, + type InstantlyCreateCampaignOutput, + instantlyGetAnalyticsNode, + InstantlyGetAnalyticsInputSchema, + InstantlyGetAnalyticsOutputSchema, + type InstantlyGetAnalyticsInput, + type InstantlyGetAnalyticsOutput, + SequenceStepSchema, + type SequenceStep, + instantlyCredential, +} from './instantly/index.js' + // Google Sheets integrations export { googleSheetsAppendNode, diff --git a/packages/nodes/src/integrations/instantly/credentials.ts b/packages/nodes/src/integrations/instantly/credentials.ts new file mode 100644 index 0000000..1a3f00d --- /dev/null +++ b/packages/nodes/src/integrations/instantly/credentials.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import { defineBearerCredential } from '@jam-nodes/core'; + +export const instantlyCredential = defineBearerCredential({ + name: 'instantly', + displayName: 'Instantly.ai API Key', + documentationUrl: 'https://developer.instantly.ai/', + schema: z.object({ + apiKey: z.string(), + }), + authenticate: { + type: 'header', + properties: { + Authorization: 'Bearer {{apiKey}}', + }, + }, +}); diff --git a/packages/nodes/src/integrations/instantly/index.ts b/packages/nodes/src/integrations/instantly/index.ts new file mode 100644 index 0000000..7d52e60 --- /dev/null +++ b/packages/nodes/src/integrations/instantly/index.ts @@ -0,0 +1,30 @@ +export { + instantlyAddLeadNode, + InstantlyAddLeadInputSchema, + InstantlyAddLeadOutputSchema, + type InstantlyAddLeadInput, + type InstantlyAddLeadOutput, +} from './instantly-add-lead.js'; + +export { + instantlyCreateCampaignNode, + InstantlyCreateCampaignInputSchema, + InstantlyCreateCampaignOutputSchema, + type InstantlyCreateCampaignInput, + type InstantlyCreateCampaignOutput, +} from './instantly-create-campaign.js'; + +export { + instantlyGetAnalyticsNode, + InstantlyGetAnalyticsInputSchema, + InstantlyGetAnalyticsOutputSchema, + type InstantlyGetAnalyticsInput, + type InstantlyGetAnalyticsOutput, +} from './instantly-get-analytics.js'; + +export { + SequenceStepSchema, + type SequenceStep, +} from './schemas.js'; + +export { instantlyCredential } from './credentials.js'; diff --git a/packages/nodes/src/integrations/instantly/instantly-add-lead.ts b/packages/nodes/src/integrations/instantly/instantly-add-lead.ts new file mode 100644 index 0000000..6ea6598 --- /dev/null +++ b/packages/nodes/src/integrations/instantly/instantly-add-lead.ts @@ -0,0 +1,106 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + InstantlyAddLeadInputSchema, + InstantlyAddLeadOutputSchema, + type InstantlyAddLeadInput, + type InstantlyAddLeadOutput, +} from './schemas.js'; + +const INSTANTLY_API_BASE = 'https://api.instantly.ai/api/v1'; + +interface InstantlyAddLeadResponse { + success?: boolean; + leadId?: string; + id?: string; + error?: string; +} + +export { + InstantlyAddLeadInputSchema, + InstantlyAddLeadOutputSchema, + type InstantlyAddLeadInput, + type InstantlyAddLeadOutput, +} from './schemas.js'; + +export const instantlyAddLeadNode = defineNode({ + type: 'instantly_add_lead', + name: 'Instantly Add Lead', + description: 'Add a lead to an Instantly.ai cold email campaign', + category: 'integration', + inputSchema: InstantlyAddLeadInputSchema, + outputSchema: InstantlyAddLeadOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + + executor: async (input: InstantlyAddLeadInput, context) => { + try { + const apiKey = context.credentials?.instantly?.apiKey; + if (!apiKey) { + return { + success: false, + error: + 'Instantly API key not configured. Please provide context.credentials.instantly.apiKey.', + }; + } + + const body: Record = { + campaignId: input.campaignId, + 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.personalization !== undefined) body.personalization = input.personalization; + if (input.customVariables !== undefined) body.customVariables = input.customVariables; + + const response = await fetchWithRetry( + `${INSTANTLY_API_BASE}/leads`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = (await response.text()).slice(0, 500); + return { + success: false, + error: `Instantly API error ${response.status}: ${errorText}`, + }; + } + + const data = (await response.json()) as InstantlyAddLeadResponse; + + if (data.success === false) { + return { + success: false, + error: `Instantly API error: ${data.error ?? 'unknown_error'}`, + }; + } + + const leadId = data.leadId ?? data.id ?? ''; + + const output: InstantlyAddLeadOutput = { + success: true, + leadId, + campaignId: input.campaignId, + email: input.email, + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to add Instantly lead', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/instantly/instantly-create-campaign.ts b/packages/nodes/src/integrations/instantly/instantly-create-campaign.ts new file mode 100644 index 0000000..20fb500 --- /dev/null +++ b/packages/nodes/src/integrations/instantly/instantly-create-campaign.ts @@ -0,0 +1,103 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + InstantlyCreateCampaignInputSchema, + InstantlyCreateCampaignOutputSchema, + type InstantlyCreateCampaignInput, + type InstantlyCreateCampaignOutput, +} from './schemas.js'; + +const INSTANTLY_API_BASE = 'https://api.instantly.ai/api/v1'; + +interface InstantlyCreateCampaignResponse { + success?: boolean; + campaignId?: string; + id?: string; + error?: string; +} + +export { + InstantlyCreateCampaignInputSchema, + InstantlyCreateCampaignOutputSchema, + type InstantlyCreateCampaignInput, + type InstantlyCreateCampaignOutput, +} from './schemas.js'; + +export const instantlyCreateCampaignNode = defineNode({ + type: 'instantly_create_campaign', + name: 'Instantly Create Campaign', + description: 'Create an Instantly.ai cold email campaign with a drip sequence', + category: 'integration', + inputSchema: InstantlyCreateCampaignInputSchema, + outputSchema: InstantlyCreateCampaignOutputSchema, + estimatedDuration: 5, + capabilities: { + supportsRerun: false, + }, + + executor: async (input: InstantlyCreateCampaignInput, context) => { + try { + const apiKey = context.credentials?.instantly?.apiKey; + if (!apiKey) { + return { + success: false, + error: + 'Instantly API key not configured. Please provide context.credentials.instantly.apiKey.', + }; + } + + const body = { + name: input.name, + emailAccounts: input.emailAccounts, + sequence: input.sequence, + }; + + const response = await fetchWithRetry( + `${INSTANTLY_API_BASE}/campaigns`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = (await response.text()).slice(0, 500); + return { + success: false, + error: `Instantly API error ${response.status}: ${errorText}`, + }; + } + + const data = (await response.json()) as InstantlyCreateCampaignResponse; + + if (data.success === false) { + return { + success: false, + error: `Instantly API error: ${data.error ?? 'unknown_error'}`, + }; + } + + const campaignId = data.campaignId ?? data.id ?? ''; + + const output: InstantlyCreateCampaignOutput = { + success: true, + campaignId, + name: input.name, + emailAccountCount: input.emailAccounts.length, + sequenceStepCount: input.sequence.length, + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create Instantly campaign', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/instantly/instantly-get-analytics.ts b/packages/nodes/src/integrations/instantly/instantly-get-analytics.ts new file mode 100644 index 0000000..7c8973f --- /dev/null +++ b/packages/nodes/src/integrations/instantly/instantly-get-analytics.ts @@ -0,0 +1,91 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + InstantlyGetAnalyticsInputSchema, + InstantlyGetAnalyticsOutputSchema, + type InstantlyGetAnalyticsInput, + type InstantlyGetAnalyticsOutput, +} from './schemas.js'; + +const INSTANTLY_API_BASE = 'https://api.instantly.ai/api/v1'; + +interface InstantlyAnalyticsResponse { + sent?: number; + opened?: number; + clicked?: number; + replied?: number; + bounced?: number; + error?: string; +} + +export { + InstantlyGetAnalyticsInputSchema, + InstantlyGetAnalyticsOutputSchema, + type InstantlyGetAnalyticsInput, + type InstantlyGetAnalyticsOutput, +} from './schemas.js'; + +export const instantlyGetAnalyticsNode = defineNode({ + type: 'instantly_get_analytics', + name: 'Instantly Get Analytics', + description: 'Fetch performance metrics for an Instantly.ai campaign', + category: 'integration', + inputSchema: InstantlyGetAnalyticsInputSchema, + outputSchema: InstantlyGetAnalyticsOutputSchema, + estimatedDuration: 2, + capabilities: { + supportsRerun: true, + }, + + executor: async (input: InstantlyGetAnalyticsInput, context) => { + try { + const apiKey = context.credentials?.instantly?.apiKey; + if (!apiKey) { + return { + success: false, + error: + 'Instantly API key not configured. Please provide context.credentials.instantly.apiKey.', + }; + } + + const url = `${INSTANTLY_API_BASE}/analytics/campaigns/${encodeURIComponent(input.campaignId)}`; + + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = (await response.text()).slice(0, 500); + return { + success: false, + error: `Instantly API error ${response.status}: ${errorText}`, + }; + } + + const data = (await response.json()) as InstantlyAnalyticsResponse; + + const output: InstantlyGetAnalyticsOutput = { + campaignId: input.campaignId, + sent: data.sent ?? 0, + opened: data.opened ?? 0, + clicked: data.clicked ?? 0, + replied: data.replied ?? 0, + bounced: data.bounced ?? 0, + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch Instantly analytics', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/instantly/instantly.test.ts b/packages/nodes/src/integrations/instantly/instantly.test.ts new file mode 100644 index 0000000..90b1860 --- /dev/null +++ b/packages/nodes/src/integrations/instantly/instantly.test.ts @@ -0,0 +1,538 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + instantlyCredential, + instantlyAddLeadNode, + instantlyCreateCampaignNode, + instantlyGetAnalyticsNode, + InstantlyAddLeadInputSchema, + InstantlyCreateCampaignInputSchema, + InstantlyGetAnalyticsInputSchema, + SequenceStepSchema, +} from './index.js'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +const makeContext = (credentials?: Record) => ({ + userId: 'u1', + workflowExecutionId: 'w1', + variables: {}, + resolveNestedPath: () => undefined, + credentials, +}); + +const makeJsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const makeErrorResponse = (status: number, text = 'error body') => + new Response(text, { status }); + +// ============================================================================= +// Credential +// ============================================================================= + +describe('instantly credential', () => { + it('defines bearer credential metadata', () => { + expect(instantlyCredential.name).toBe('instantly'); + expect(instantlyCredential.type).toBe('bearer'); + expect(instantlyCredential.authenticate.properties.Authorization).toBe( + 'Bearer {{apiKey}}' + ); + }); + + it('credential schema parses a valid apiKey', () => { + const result = instantlyCredential.schema.safeParse({ apiKey: 'ins_abc123' }); + expect(result.success).toBe(true); + }); + + it('credential schema rejects missing apiKey', () => { + const result = instantlyCredential.schema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +// ============================================================================= +// Schemas +// ============================================================================= + +describe('instantly schemas', () => { + it('validates add lead input with required fields only', () => { + const result = InstantlyAddLeadInputSchema.safeParse({ + campaignId: 'camp_123', + email: 'user@example.com', + }); + expect(result.success).toBe(true); + }); + + it('validates add lead input with all optional fields populated', () => { + const result = InstantlyAddLeadInputSchema.safeParse({ + campaignId: 'camp_123', + email: 'user@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + companyName: 'Analytical Engines Inc', + personalization: 'Loved your talk on difference engines', + customVariables: { tier: 'gold', signupYear: 1843 }, + }); + expect(result.success).toBe(true); + }); + + it('rejects add lead input with invalid email', () => { + const result = InstantlyAddLeadInputSchema.safeParse({ + campaignId: 'camp_123', + email: 'not-an-email', + }); + expect(result.success).toBe(false); + }); + + it('rejects add lead input with empty campaignId', () => { + const result = InstantlyAddLeadInputSchema.safeParse({ + campaignId: '', + email: 'user@example.com', + }); + expect(result.success).toBe(false); + }); + + it('validates create campaign with multi-step sequence', () => { + const result = InstantlyCreateCampaignInputSchema.safeParse({ + name: 'Q2 Outreach', + emailAccounts: ['sender1@ex.com', 'sender2@ex.com'], + sequence: [ + { subject: 'Hi there', body: 'First touch', delayDays: 0 }, + { subject: 'Following up', body: 'Second touch', delayDays: 3 }, + { subject: 'Last try', body: 'Final touch', delayDays: 7 }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects create campaign with empty sequence array', () => { + const result = InstantlyCreateCampaignInputSchema.safeParse({ + name: 'Broken', + emailAccounts: ['a@b.com'], + sequence: [], + }); + expect(result.success).toBe(false); + }); + + it('rejects create campaign with empty emailAccounts array', () => { + const result = InstantlyCreateCampaignInputSchema.safeParse({ + name: 'Broken', + emailAccounts: [], + sequence: [{ subject: 'Hi', body: 'Body', delayDays: 0 }], + }); + expect(result.success).toBe(false); + }); + + it('rejects sequence step with negative delayDays', () => { + const result = SequenceStepSchema.safeParse({ + subject: 'Hi', + body: 'Body', + delayDays: -1, + }); + expect(result.success).toBe(false); + }); + + it('validates get analytics input', () => { + const result = InstantlyGetAnalyticsInputSchema.safeParse({ campaignId: 'camp_123' }); + expect(result.success).toBe(true); + }); + + it('rejects get analytics with empty campaignId', () => { + const result = InstantlyGetAnalyticsInputSchema.safeParse({ campaignId: '' }); + expect(result.success).toBe(false); + }); +}); + +// ============================================================================= +// instantly_add_lead +// ============================================================================= + +describe('instantly_add_lead', () => { + it('fails when api key is missing', async () => { + const result = await instantlyAddLeadNode.executor( + { campaignId: 'camp_123', email: 'user@example.com' }, + makeContext() + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('instantly.apiKey'); + }); + + it('adds lead and returns leadId', async () => { + const fetchMock = vi.fn().mockResolvedValue( + makeJsonResponse({ success: true, leadId: 'lead_789' }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyAddLeadNode.executor( + { campaignId: 'camp_123', email: 'user@example.com' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(true); + expect(result.output?.leadId).toBe('lead_789'); + expect(result.output?.campaignId).toBe('camp_123'); + expect(result.output?.email).toBe('user@example.com'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.instantly.ai/api/v1/leads'); + expect((init.headers as Record).Authorization).toBe('Bearer test_key'); + expect(init.method).toBe('POST'); + }); + + it('includes optional fields in request body when provided', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeJsonResponse({ success: true, leadId: 'lead_1' })); + vi.stubGlobal('fetch', fetchMock); + + await instantlyAddLeadNode.executor( + { + campaignId: 'camp_123', + email: 'user@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + companyName: 'Acme', + personalization: 'hello', + customVariables: { tier: 'gold' }, + }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(String(init.body)); + expect(body.firstName).toBe('Ada'); + expect(body.lastName).toBe('Lovelace'); + expect(body.companyName).toBe('Acme'); + expect(body.personalization).toBe('hello'); + expect(body.customVariables).toEqual({ tier: 'gold' }); + }); + + it('omits optional fields from request body when undefined', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeJsonResponse({ success: true, leadId: 'lead_1' })); + vi.stubGlobal('fetch', fetchMock); + + await instantlyAddLeadNode.executor( + { campaignId: 'camp_123', email: 'user@example.com' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(String(init.body)); + expect(body).toEqual({ campaignId: 'camp_123', email: 'user@example.com' }); + expect(body.firstName).toBeUndefined(); + expect(body.customVariables).toBeUndefined(); + }); + + it('returns failure on 4xx API response', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeErrorResponse(400, 'invalid_campaign')); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyAddLeadNode.executor( + { campaignId: 'bad', email: 'user@example.com' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('400'); + expect(result.error).toContain('invalid_campaign'); + }); + + it('truncates oversized error response text to 500 chars', async () => { + const huge = 'x'.repeat(5000); + const fetchMock = vi.fn().mockResolvedValue(makeErrorResponse(400, huge)); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyAddLeadNode.executor( + { campaignId: 'camp_123', email: 'user@example.com' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(false); + // The prefix is "Instantly API error 400: " (25 chars) + truncated body (500 chars) = 525 total + expect(result.error!.length).toBeLessThanOrEqual(600); + expect(result.error!.length).toBeGreaterThanOrEqual(500); + // Original body was 5000 'x's — confirm truncation really happened + expect((result.error!.match(/x/g) ?? []).length).toBe(500); + }); + + it('falls back to data.id when data.leadId is absent', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeJsonResponse({ id: 'fallback_id' })); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyAddLeadNode.executor( + { campaignId: 'camp_123', email: 'user@example.com' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(true); + expect(result.output?.leadId).toBe('fallback_id'); + }); + + it('returns failure when response body says success: false', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeJsonResponse({ success: false, error: 'duplicate_email' })); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyAddLeadNode.executor( + { campaignId: 'camp_123', email: 'dup@example.com' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('duplicate_email'); + }); + + it('returns failure when fetch throws (network error)', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + vi.stubGlobal('fetch', fetchMock); + + const promise = instantlyAddLeadNode.executor( + { campaignId: 'camp_123', email: 'user@example.com' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); +}); + +// ============================================================================= +// instantly_create_campaign +// ============================================================================= + +describe('instantly_create_campaign', () => { + const validInput = { + name: 'Q2 Outreach', + emailAccounts: ['sender@ex.com'], + sequence: [ + { subject: 'Hi', body: 'First touch', delayDays: 0 }, + { subject: 'Follow-up', body: 'Second touch', delayDays: 3 }, + ], + }; + + it('fails when api key is missing', async () => { + const result = await instantlyCreateCampaignNode.executor(validInput, makeContext()); + + expect(result.success).toBe(false); + expect(result.error).toContain('instantly.apiKey'); + }); + + it('creates campaign and returns campaignId with counts', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeJsonResponse({ success: true, campaignId: 'camp_new' })); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyCreateCampaignNode.executor( + validInput, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(true); + expect(result.output?.campaignId).toBe('camp_new'); + expect(result.output?.name).toBe('Q2 Outreach'); + expect(result.output?.emailAccountCount).toBe(1); + expect(result.output?.sequenceStepCount).toBe(2); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.instantly.ai/api/v1/campaigns'); + expect(init.method).toBe('POST'); + expect((init.headers as Record).Authorization).toBe('Bearer test_key'); + + const body = JSON.parse(String(init.body)); + expect(body.name).toBe('Q2 Outreach'); + expect(body.emailAccounts).toEqual(['sender@ex.com']); + expect(body.sequence).toHaveLength(2); + expect(body.sequence[0]).toEqual({ subject: 'Hi', body: 'First touch', delayDays: 0 }); + expect(body.sequence[1]).toEqual({ + subject: 'Follow-up', + body: 'Second touch', + delayDays: 3, + }); + }); + + it('returns failure on 4xx API response', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeErrorResponse(422, 'validation_failed')); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyCreateCampaignNode.executor( + validInput, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('422'); + expect(result.error).toContain('validation_failed'); + }); + + it('falls back to data.id when data.campaignId is absent', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeJsonResponse({ id: 'fallback_camp' })); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyCreateCampaignNode.executor( + validInput, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(true); + expect(result.output?.campaignId).toBe('fallback_camp'); + }); + + it('returns failure when response body says success: false', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeJsonResponse({ success: false, error: 'account_not_verified' })); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyCreateCampaignNode.executor( + validInput, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('account_not_verified'); + }); + + it('returns failure when fetch throws (network error)', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockRejectedValue(new Error('ENOTFOUND')); + vi.stubGlobal('fetch', fetchMock); + + const promise = instantlyCreateCampaignNode.executor( + validInput, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); +}); + +// ============================================================================= +// instantly_get_analytics +// ============================================================================= + +describe('instantly_get_analytics', () => { + it('fails when api key is missing', async () => { + const result = await instantlyGetAnalyticsNode.executor( + { campaignId: 'camp_123' }, + makeContext() + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('instantly.apiKey'); + }); + + it('returns all analytics metrics', async () => { + const fetchMock = vi.fn().mockResolvedValue( + makeJsonResponse({ + sent: 100, + opened: 50, + clicked: 25, + replied: 10, + bounced: 5, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyGetAnalyticsNode.executor( + { campaignId: 'camp_123' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(true); + expect(result.output).toEqual({ + campaignId: 'camp_123', + sent: 100, + opened: 50, + clicked: 25, + replied: 10, + bounced: 5, + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.instantly.ai/api/v1/analytics/campaigns/camp_123'); + expect(init.method).toBe('GET'); + expect((init.headers as Record).Authorization).toBe('Bearer test_key'); + }); + + it('defaults all analytics metrics to 0 when API response omits them', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeJsonResponse({})); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyGetAnalyticsNode.executor( + { campaignId: 'camp_empty' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(true); + expect(result.output).toEqual({ + campaignId: 'camp_empty', + sent: 0, + opened: 0, + clicked: 0, + replied: 0, + bounced: 0, + }); + }); + + it('url-encodes campaignId with special characters', async () => { + const fetchMock = vi.fn().mockResolvedValue( + makeJsonResponse({ sent: 0, opened: 0, clicked: 0, replied: 0, bounced: 0 }) + ); + vi.stubGlobal('fetch', fetchMock); + + await instantlyGetAnalyticsNode.executor( + { campaignId: 'camp/with spaces' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe( + 'https://api.instantly.ai/api/v1/analytics/campaigns/camp%2Fwith%20spaces' + ); + }); + + it('returns failure on 4xx API response', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(makeErrorResponse(404, 'campaign_not_found')); + vi.stubGlobal('fetch', fetchMock); + + const result = await instantlyGetAnalyticsNode.executor( + { campaignId: 'nope' }, + makeContext({ instantly: { apiKey: 'test_key' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('404'); + expect(result.error).toContain('campaign_not_found'); + }); +}); diff --git a/packages/nodes/src/integrations/instantly/schemas.ts b/packages/nodes/src/integrations/instantly/schemas.ts new file mode 100644 index 0000000..7d89fd8 --- /dev/null +++ b/packages/nodes/src/integrations/instantly/schemas.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; + +// ============================================================================= +// Shared: Sequence Step +// ============================================================================= + +export const SequenceStepSchema = z.object({ + /** Email subject line for this step */ + subject: z.string().min(1), + /** Email body for this step (plain text or HTML) */ + body: z.string().min(1), + /** Days to wait after the previous step before sending this one */ + delayDays: z.number().int().min(0), +}); + +export type SequenceStep = z.infer; + +// ============================================================================= +// Add Lead +// ============================================================================= + +export const InstantlyAddLeadInputSchema = z.object({ + /** The Instantly campaign ID to add the lead to */ + campaignId: z.string().min(1), + /** The lead's email address */ + email: z.string().email(), + /** Lead's first name */ + firstName: z.string().optional(), + /** Lead's last name */ + lastName: z.string().optional(), + /** Lead's company name */ + companyName: z.string().optional(), + /** Personalization snippet inserted into the email */ + personalization: z.string().optional(), + /** Arbitrary custom variables merged into the lead profile (max 50 keys) */ + customVariables: z + .record(z.string(), z.unknown()) + .refine((obj) => Object.keys(obj).length <= 50, { + message: 'customVariables may not have more than 50 keys', + }) + .optional(), +}); + +export const InstantlyAddLeadOutputSchema = z.object({ + success: z.boolean(), + leadId: z.string(), + campaignId: z.string(), + email: z.string(), +}); + +export type InstantlyAddLeadInput = z.infer; +export type InstantlyAddLeadOutput = z.infer; + +// ============================================================================= +// Create Campaign +// ============================================================================= + +export const InstantlyCreateCampaignInputSchema = z.object({ + /** Human-readable campaign name */ + name: z.string().min(1), + /** IDs or addresses of sending accounts attached to the campaign (max 50) */ + emailAccounts: z.array(z.string().min(1)).min(1).max(50), + /** Ordered drip sequence — at least one step required, max 100 steps */ + sequence: z.array(SequenceStepSchema).min(1).max(100), +}); + +export const InstantlyCreateCampaignOutputSchema = z.object({ + success: z.boolean(), + campaignId: z.string(), + name: z.string(), + emailAccountCount: z.number(), + sequenceStepCount: z.number(), +}); + +export type InstantlyCreateCampaignInput = z.infer; +export type InstantlyCreateCampaignOutput = z.infer; + +// ============================================================================= +// Get Analytics +// ============================================================================= + +export const InstantlyGetAnalyticsInputSchema = z.object({ + /** The campaign ID to fetch analytics for */ + campaignId: z.string().min(1), +}); + +export const InstantlyGetAnalyticsOutputSchema = z.object({ + campaignId: z.string(), + sent: z.number(), + opened: z.number(), + clicked: z.number(), + replied: z.number(), + bounced: z.number(), +}); + +export type InstantlyGetAnalyticsInput = z.infer; +export type InstantlyGetAnalyticsOutput = z.infer;