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;