diff --git a/apps/sim/blocks/blocks/loops.ts b/apps/sim/blocks/blocks/loops.ts index 76e27e48cb0..c71ce37e1d4 100644 --- a/apps/sim/blocks/blocks/loops.ts +++ b/apps/sim/blocks/blocks/loops.ts @@ -32,6 +32,9 @@ export const LoopsBlock: BlockConfig = { { label: 'List Transactional Emails', id: 'list_transactional_emails' }, { label: 'Create Contact Property', id: 'create_contact_property' }, { label: 'List Contact Properties', id: 'list_contact_properties' }, + { label: 'Check Contact Suppression', id: 'check_contact_suppression' }, + { label: 'Remove Contact Suppression', id: 'remove_contact_suppression' }, + { label: 'Get Transactional Email', id: 'get_transactional_email' }, ], value: () => 'create_contact', }, @@ -47,7 +50,7 @@ export const LoopsBlock: BlockConfig = { value: ['create_contact', 'send_transactional_email'], }, }, - // Optional email for update, find, delete, send event + // Optional email for update, find, delete, send event, suppression lookups { id: 'contactEmail', title: 'Email', @@ -55,7 +58,14 @@ export const LoopsBlock: BlockConfig = { placeholder: 'Enter email address', condition: { field: 'operation', - value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'], + value: [ + 'update_contact', + 'find_contact', + 'delete_contact', + 'send_event', + 'check_contact_suppression', + 'remove_contact_suppression', + ], }, }, // User ID for operations that support it @@ -66,7 +76,14 @@ export const LoopsBlock: BlockConfig = { placeholder: 'Enter user ID', condition: { field: 'operation', - value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'], + value: [ + 'update_contact', + 'find_contact', + 'delete_contact', + 'send_event', + 'check_contact_suppression', + 'remove_contact_suppression', + ], }, }, // Contact fields @@ -199,10 +216,13 @@ Return ONLY the JSON object - no explanations, no extra text.`, title: 'Transactional Email ID', type: 'short-input', placeholder: 'Enter template ID (e.g., clx...)', - required: { field: 'operation', value: 'send_transactional_email' }, + required: { + field: 'operation', + value: ['send_transactional_email', 'get_transactional_email'], + }, condition: { field: 'operation', - value: 'send_transactional_email', + value: ['send_transactional_email', 'get_transactional_email'], }, }, { @@ -426,6 +446,9 @@ Return ONLY the JSON object - no explanations, no extra text.`, 'loops_list_transactional_emails', 'loops_create_contact_property', 'loops_list_contact_properties', + 'loops_check_contact_suppression', + 'loops_remove_contact_suppression', + 'loops_get_transactional_email', ], config: { tool: (params) => `loops_${params.operation}`, @@ -497,6 +520,16 @@ Return ONLY the JSON object - no explanations, no extra text.`, case 'list_contact_properties': if (params.propertyFilter) result.list = params.propertyFilter break + + case 'check_contact_suppression': + case 'remove_contact_suppression': + if (params.contactEmail) result.email = params.contactEmail + if (params.userId) result.userId = params.userId + break + + case 'get_transactional_email': + result.transactionalId = params.transactionalId + break } return result @@ -531,7 +564,10 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, outputs: { success: { type: 'boolean', description: 'Whether the operation succeeded' }, - id: { type: 'string', description: 'Contact ID (create/update operations)' }, + id: { + type: 'string', + description: 'Contact ID (create/update operations) or template ID (get transactional email)', + }, contacts: { type: 'json', description: @@ -544,7 +580,8 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, transactionalEmails: { type: 'json', - description: 'Array of transactional email templates (id, name, lastUpdated, dataVariables)', + description: + 'Array of transactional email templates (id, name, createdAt, updatedAt, lastUpdated (deprecated alias of updatedAt), dataVariables)', }, pagination: { type: 'json', @@ -555,6 +592,50 @@ Return ONLY the JSON object - no explanations, no extra text.`, type: 'json', description: 'Array of contact properties (key, label, type)', }, + isSuppressed: { + type: 'boolean', + description: 'Whether the contact is on the suppression list (check suppression)', + }, + contactId: { + type: 'string', + description: 'The Loops-assigned contact ID (check suppression)', + }, + removalQuotaLimit: { + type: 'number', + description: 'Total suppression-removal quota for the team', + }, + removalQuotaRemaining: { + type: 'number', + description: 'Remaining suppression-removal quota for the team', + }, + name: { + type: 'string', + description: 'Transactional email template name (get transactional email)', + }, + draftEmailMessageId: { + type: 'string', + description: 'ID of the draft email message, if any (get transactional email)', + }, + publishedEmailMessageId: { + type: 'string', + description: 'ID of the published email message, if any (get transactional email)', + }, + transactionalGroupId: { + type: 'string', + description: 'ID of the transactional group, if any (get transactional email)', + }, + createdAt: { + type: 'string', + description: 'Creation timestamp (get transactional email)', + }, + updatedAt: { + type: 'string', + description: 'Last updated timestamp (get transactional email)', + }, + dataVariables: { + type: 'json', + description: 'Template data variable names (get transactional email)', + }, }, } @@ -655,5 +736,12 @@ export const LoopsBlockMeta = { content: '# Send Transactional Email\n\nDeliver a templated transactional email through Loops.\n\n## Steps\n1. Confirm the transactional email template ID to use.\n2. Build the data variables JSON to match the variable names in the template, such as name and a confirmation URL.\n3. Send Transactional Email with the recipient email, template ID, and data variables, attaching files if needed.\n\n## Output\nConfirmation of send success and the template ID and recipient used.', }, + { + name: 'manage-suppression-compliance', + description: + 'Check and clear Loops suppression status for a contact to keep deliverability and unsubscribe compliance in check.', + content: + '# Manage Suppression Compliance\n\nKeep Loops sending compliant and deliverable.\n\n## Steps\n1. Check Contact Suppression by email or user ID to see if the contact bounced, complained, or unsubscribed.\n2. If the contact should be re-enabled (e.g. a confirmed re-opt-in), Remove Contact Suppression for the same identifier, noting the remaining removal quota.\n3. Log the result so support and compliance workflows have an audit trail.\n\n## Output\nThe suppression status before and after the change, plus the remaining removal quota.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/loops/check_contact_suppression.ts b/apps/sim/tools/loops/check_contact_suppression.ts new file mode 100644 index 00000000000..bcc8aa922e0 --- /dev/null +++ b/apps/sim/tools/loops/check_contact_suppression.ts @@ -0,0 +1,104 @@ +import type { + LoopsCheckContactSuppressionParams, + LoopsCheckContactSuppressionResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsCheckContactSuppressionTool: ToolConfig< + LoopsCheckContactSuppressionParams, + LoopsCheckContactSuppressionResponse +> = { + id: 'loops_check_contact_suppression', + name: 'Loops Check Contact Suppression', + description: + 'Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The contact email address to check (at least one of email or userId is required)', + }, + userId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The contact userId to check (at least one of email or userId is required)', + }, + }, + + request: { + url: (params) => { + if (!params.email && !params.userId) { + throw new Error('At least one of email or userId is required to check suppression status') + } + const base = 'https://app.loops.so/api/v1/contacts/suppression' + if (params.email) return `${base}?email=${encodeURIComponent(params.email.trim())}` + return `${base}?userId=${encodeURIComponent(params.userId!.trim())}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (data.isSuppressed == null) { + return { + success: false, + output: { + contactId: null, + email: null, + userId: null, + isSuppressed: false, + removalQuotaLimit: null, + removalQuotaRemaining: null, + }, + error: data.message ?? 'Failed to check contact suppression status', + } + } + + return { + success: true, + output: { + contactId: (data.contact?.id as string) ?? null, + email: (data.contact?.email as string) ?? null, + userId: (data.contact?.userId as string) ?? null, + isSuppressed: (data.isSuppressed as boolean) ?? false, + removalQuotaLimit: (data.removalQuota?.limit as number) ?? null, + removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null, + }, + } + }, + + outputs: { + contactId: { type: 'string', description: 'The Loops-assigned contact ID', optional: true }, + email: { type: 'string', description: 'The contact email address', optional: true }, + userId: { type: 'string', description: 'The contact userId', optional: true }, + isSuppressed: { + type: 'boolean', + description: 'Whether the contact is on the suppression list', + }, + removalQuotaLimit: { + type: 'number', + description: 'Total suppression-removal quota for the team', + optional: true, + }, + removalQuotaRemaining: { + type: 'number', + description: 'Remaining suppression-removal quota for the team', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/loops/get_transactional_email.ts b/apps/sim/tools/loops/get_transactional_email.ts new file mode 100644 index 00000000000..55fd75d6f16 --- /dev/null +++ b/apps/sim/tools/loops/get_transactional_email.ts @@ -0,0 +1,106 @@ +import type { + LoopsGetTransactionalEmailParams, + LoopsGetTransactionalEmailResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsGetTransactionalEmailTool: ToolConfig< + LoopsGetTransactionalEmailParams, + LoopsGetTransactionalEmailResponse +> = { + id: 'loops_get_transactional_email', + name: 'Loops Get Transactional Email', + description: + 'Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + transactionalId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the transactional email template to retrieve', + }, + }, + + request: { + url: (params) => + `https://app.loops.so/api/v1/transactional-emails/${encodeURIComponent(params.transactionalId.trim())}`, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.id) { + return { + success: false, + output: { + id: null, + name: null, + draftEmailMessageId: null, + publishedEmailMessageId: null, + transactionalGroupId: null, + createdAt: null, + updatedAt: null, + dataVariables: [], + }, + error: data.message ?? 'Failed to get transactional email', + } + } + + return { + success: true, + output: { + id: (data.id as string) ?? null, + name: (data.name as string) ?? null, + draftEmailMessageId: (data.draftEmailMessageId as string) ?? null, + publishedEmailMessageId: (data.publishedEmailMessageId as string) ?? null, + transactionalGroupId: (data.transactionalGroupId as string) ?? null, + createdAt: (data.createdAt as string) ?? null, + updatedAt: (data.updatedAt as string) ?? null, + dataVariables: (data.dataVariables as string[]) ?? [], + }, + } + }, + + outputs: { + id: { type: 'string', description: 'The transactional email template ID', optional: true }, + name: { type: 'string', description: 'The template name', optional: true }, + draftEmailMessageId: { + type: 'string', + description: 'ID of the draft email message, if any', + optional: true, + }, + publishedEmailMessageId: { + type: 'string', + description: 'ID of the published email message, if any', + optional: true, + }, + transactionalGroupId: { + type: 'string', + description: 'ID of the transactional group this template belongs to, if any', + optional: true, + }, + createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)', optional: true }, + updatedAt: { + type: 'string', + description: 'Last updated timestamp (ISO 8601)', + optional: true, + }, + dataVariables: { + type: 'array', + description: 'Template data variable names', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/loops/index.ts b/apps/sim/tools/loops/index.ts index bad17376cf5..baa5480634c 100644 --- a/apps/sim/tools/loops/index.ts +++ b/apps/sim/tools/loops/index.ts @@ -1,10 +1,13 @@ +export { loopsCheckContactSuppressionTool } from '@/tools/loops/check_contact_suppression' export { loopsCreateContactTool } from '@/tools/loops/create_contact' export { loopsCreateContactPropertyTool } from '@/tools/loops/create_contact_property' export { loopsDeleteContactTool } from '@/tools/loops/delete_contact' export { loopsFindContactTool } from '@/tools/loops/find_contact' +export { loopsGetTransactionalEmailTool } from '@/tools/loops/get_transactional_email' export { loopsListContactPropertiesTool } from '@/tools/loops/list_contact_properties' export { loopsListMailingListsTool } from '@/tools/loops/list_mailing_lists' export { loopsListTransactionalEmailsTool } from '@/tools/loops/list_transactional_emails' +export { loopsRemoveContactSuppressionTool } from '@/tools/loops/remove_contact_suppression' export { loopsSendEventTool } from '@/tools/loops/send_event' export { loopsSendTransactionalEmailTool } from '@/tools/loops/send_transactional_email' export { loopsUpdateContactTool } from '@/tools/loops/update_contact' diff --git a/apps/sim/tools/loops/list_transactional_emails.ts b/apps/sim/tools/loops/list_transactional_emails.ts index bd36d373222..30703e87a31 100644 --- a/apps/sim/tools/loops/list_transactional_emails.ts +++ b/apps/sim/tools/loops/list_transactional_emails.ts @@ -11,7 +11,7 @@ export const loopsListTransactionalEmailsTool: ToolConfig< id: 'loops_list_transactional_emails', name: 'Loops List Transactional Emails', description: - 'Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables.', + 'Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables.', version: '1.0.0', params: { @@ -37,7 +37,7 @@ export const loopsListTransactionalEmailsTool: ToolConfig< request: { url: (params) => { - const base = 'https://app.loops.so/api/v1/transactional' + const base = 'https://app.loops.so/api/v1/transactional-emails' const queryParams: string[] = [] if (params.perPage) queryParams.push(`perPage=${encodeURIComponent(params.perPage)}`) if (params.cursor) queryParams.push(`cursor=${encodeURIComponent(params.cursor)}`) @@ -78,7 +78,11 @@ export const loopsListTransactionalEmailsTool: ToolConfig< transactionalEmails: emails.map((email: Record) => ({ id: (email.id as string) ?? '', name: (email.name as string) ?? '', - lastUpdated: (email.lastUpdated as string) ?? '', + createdAt: (email.createdAt as string) ?? '', + updatedAt: (email.updatedAt as string) ?? '', + // Deprecated alias of updatedAt, kept for backwards compatibility with the old + // (now-removed) /api/v1/transactional list endpoint, which returned this field. + lastUpdated: (email.updatedAt as string) ?? '', dataVariables: (email.dataVariables as string[]) ?? [], })), pagination: { @@ -102,7 +106,12 @@ export const loopsListTransactionalEmailsTool: ToolConfig< properties: { id: { type: 'string', description: 'The transactional email template ID' }, name: { type: 'string', description: 'The template name' }, - lastUpdated: { type: 'string', description: 'Last updated timestamp' }, + createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)' }, + updatedAt: { type: 'string', description: 'Last updated timestamp (ISO 8601)' }, + lastUpdated: { + type: 'string', + description: 'Deprecated alias of updatedAt, kept for backwards compatibility', + }, dataVariables: { type: 'array', description: 'Template data variable names', diff --git a/apps/sim/tools/loops/remove_contact_suppression.ts b/apps/sim/tools/loops/remove_contact_suppression.ts new file mode 100644 index 00000000000..0198d3b13f7 --- /dev/null +++ b/apps/sim/tools/loops/remove_contact_suppression.ts @@ -0,0 +1,99 @@ +import type { + LoopsRemoveContactSuppressionParams, + LoopsRemoveContactSuppressionResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsRemoveContactSuppressionTool: ToolConfig< + LoopsRemoveContactSuppressionParams, + LoopsRemoveContactSuppressionResponse +> = { + id: 'loops_remove_contact_suppression', + name: 'Loops Remove Contact Suppression', + description: + 'Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The contact email address to remove from suppression (at least one of email or userId is required)', + }, + userId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The contact userId to remove from suppression (at least one of email or userId is required)', + }, + }, + + request: { + url: (params) => { + if (!params.email && !params.userId) { + throw new Error('At least one of email or userId is required to remove suppression') + } + const base = 'https://app.loops.so/api/v1/contacts/suppression' + if (params.email) return `${base}?email=${encodeURIComponent(params.email.trim())}` + return `${base}?userId=${encodeURIComponent(params.userId!.trim())}` + }, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + success: false, + message: data.message ?? 'Failed to remove contact suppression', + removalQuotaLimit: (data.removalQuota?.limit as number) ?? null, + removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null, + }, + error: data.message ?? 'Failed to remove contact suppression', + } + } + + return { + success: true, + output: { + success: true, + message: data.message ?? 'Contact removed from suppression list.', + removalQuotaLimit: (data.removalQuota?.limit as number) ?? null, + removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the contact was removed from suppression successfully', + }, + message: { type: 'string', description: 'Status message from the API', optional: true }, + removalQuotaLimit: { + type: 'number', + description: 'Total suppression-removal quota for the team', + optional: true, + }, + removalQuotaRemaining: { + type: 'number', + description: 'Remaining suppression-removal quota for the team', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/loops/types.ts b/apps/sim/tools/loops/types.ts index da3f81d4c91..79b0e3626b6 100644 --- a/apps/sim/tools/loops/types.ts +++ b/apps/sim/tools/loops/types.ts @@ -70,6 +70,20 @@ export interface LoopsListContactPropertiesParams extends LoopsBaseParams { list?: string } +export interface LoopsCheckContactSuppressionParams extends LoopsBaseParams { + email?: string + userId?: string +} + +export interface LoopsRemoveContactSuppressionParams extends LoopsBaseParams { + email?: string + userId?: string +} + +export interface LoopsGetTransactionalEmailParams extends LoopsBaseParams { + transactionalId: string +} + interface LoopsContact { id: string email: string @@ -138,6 +152,9 @@ export interface LoopsListTransactionalEmailsResponse extends ToolResponse { transactionalEmails: { id: string name: string + createdAt: string + updatedAt: string + /** @deprecated Alias of updatedAt, kept for backwards compatibility */ lastUpdated: string dataVariables: string[] }[] @@ -168,6 +185,39 @@ export interface LoopsListContactPropertiesResponse extends ToolResponse { } } +export interface LoopsCheckContactSuppressionResponse extends ToolResponse { + output: { + contactId: string | null + email: string | null + userId: string | null + isSuppressed: boolean + removalQuotaLimit: number | null + removalQuotaRemaining: number | null + } +} + +export interface LoopsRemoveContactSuppressionResponse extends ToolResponse { + output: { + success: boolean + message: string | null + removalQuotaLimit: number | null + removalQuotaRemaining: number | null + } +} + +export interface LoopsGetTransactionalEmailResponse extends ToolResponse { + output: { + id: string | null + name: string | null + draftEmailMessageId: string | null + publishedEmailMessageId: string | null + transactionalGroupId: string | null + createdAt: string | null + updatedAt: string | null + dataVariables: string[] + } +} + export type LoopsResponse = | LoopsCreateContactResponse | LoopsUpdateContactResponse @@ -179,6 +229,9 @@ export type LoopsResponse = | LoopsListTransactionalEmailsResponse | LoopsCreateContactPropertyResponse | LoopsListContactPropertiesResponse + | LoopsCheckContactSuppressionResponse + | LoopsRemoveContactSuppressionResponse + | LoopsGetTransactionalEmailResponse export const LOOPS_CONTACT_OUTPUT_PROPERTIES = { id: { type: 'string' as const, description: 'Loops-assigned contact ID' }, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2bb910c4eef..1e8114b93bc 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2065,13 +2065,16 @@ import { logsQueryTool, } from '@/tools/logs' import { + loopsCheckContactSuppressionTool, loopsCreateContactPropertyTool, loopsCreateContactTool, loopsDeleteContactTool, loopsFindContactTool, + loopsGetTransactionalEmailTool, loopsListContactPropertiesTool, loopsListMailingListsTool, loopsListTransactionalEmailsTool, + loopsRemoveContactSuppressionTool, loopsSendEventTool, loopsSendTransactionalEmailTool, loopsUpdateContactTool, @@ -4633,16 +4636,19 @@ export const tools: Record = { logs_get: logsGetTool, logs_get_execution: logsGetExecutionTool, logs_get_run_details: logsGetRunDetailsTool, + loops_check_contact_suppression: loopsCheckContactSuppressionTool, loops_create_contact: loopsCreateContactTool, loops_create_contact_property: loopsCreateContactPropertyTool, - loops_update_contact: loopsUpdateContactTool, - loops_find_contact: loopsFindContactTool, loops_delete_contact: loopsDeleteContactTool, + loops_find_contact: loopsFindContactTool, + loops_get_transactional_email: loopsGetTransactionalEmailTool, loops_list_contact_properties: loopsListContactPropertiesTool, loops_list_mailing_lists: loopsListMailingListsTool, loops_list_transactional_emails: loopsListTransactionalEmailsTool, - loops_send_transactional_email: loopsSendTransactionalEmailTool, + loops_remove_contact_suppression: loopsRemoveContactSuppressionTool, loops_send_event: loopsSendEventTool, + loops_send_transactional_email: loopsSendTransactionalEmailTool, + loops_update_contact: loopsUpdateContactTool, luma_add_guests: lumaAddGuestsTool, luma_cancel_event: lumaCancelEventTool, luma_create_event: lumaCreateEventTool,