From 07c7fff6b758709b563c164eed94c67319fcbece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Poitras?= Date: Wed, 10 Sep 2025 09:36:52 -0400 Subject: [PATCH 1/3] chore(integrations/intercom,cicd): Temporarily remove OAuth, reenable deployment and make client secret optional (#14223) --- .../deploy-integrations-production.yml | 1 - .../intercom/integration.definition.ts | 54 +++++++++++-------- integrations/intercom/src/auth.ts | 8 +-- integrations/intercom/src/index.ts | 2 +- 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/.github/workflows/deploy-integrations-production.yml b/.github/workflows/deploy-integrations-production.yml index 90f84eab826..59d3d0e423a 100644 --- a/.github/workflows/deploy-integrations-production.yml +++ b/.github/workflows/deploy-integrations-production.yml @@ -31,7 +31,6 @@ jobs: uses: ./.github/actions/deploy-integrations with: environment: 'production' - extra_filter: "-F '!intercom'" force: ${{ github.event.inputs.force == 'true' }} sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }} diff --git a/integrations/intercom/integration.definition.ts b/integrations/intercom/integration.definition.ts index 27c8cdfcdc0..f9d39a3be2d 100644 --- a/integrations/intercom/integration.definition.ts +++ b/integrations/intercom/integration.definition.ts @@ -10,30 +10,42 @@ export default new IntegrationDefinition({ icon: 'icon.svg', readme: 'hub.md', configuration: { - identifier: { - linkTemplateScript: 'linkTemplate.vrl', - required: true, - }, schema: z.object({ adminId: z.string().min(1).describe('The admin ID of the Bot'), + accessToken: z.string().min(1).describe('The access token of the Intercom app'), + clientSecret: z + .string() + .min(1) + .secret() + .optional() + .describe('The client secret of the Intercom app, used for event signature validation'), }), - }, - configurations: { - manual: { - title: 'Manual Configuration', - description: 'Manual configuration, use your own Intercom app (for advanced use cases only)', - schema: z.object({ - adminId: z.string().min(1).describe('The admin ID of the Bot'), - accessToken: z.string().min(1).describe('The access token of the Intercom app'), - clientSecret: z - .string() - .min(1) - .secret() - .describe( - 'The client secret of the Intercom app. Required for event signature validation, even if not authenticated by OAuth' - ), - }), - }, + // TODO: Uncomment this once the Intercom app is approved + // identifier: { + // linkTemplateScript: 'linkTemplate.vrl', + // required: true, + // }, + // schema: z.object({ + // adminId: z.string().min(1).describe('The admin ID of the Bot'), + // }), + // }, + // configurations: { + // manual: { + // title: 'Manual Configuration', + // description: 'Manual configuration, use your own Intercom app (for advanced use cases only)', + // schema: z.object({ + // adminId: z.string().min(1).describe('The admin ID of the Bot'), + // accessToken: z.string().min(1).describe('The access token of the Intercom app'), + // clientSecret: z + // .string() + // .min(1) + // .secret() + // .describe( + // 'The client secret of the Intercom app. Required for event signature validation, even if not authenticated by OAuth' + // ), + // }), + // }, + // }, }, channels: { channel: { diff --git a/integrations/intercom/src/auth.ts b/integrations/intercom/src/auth.ts index a6779eea93c..15e32100af8 100644 --- a/integrations/intercom/src/auth.ts +++ b/integrations/intercom/src/auth.ts @@ -4,7 +4,8 @@ import { Client as IntercomClient } from 'intercom-client' import * as bp from '.botpress' export const getAuthenticatedIntercomClient = async (client: bp.Client, ctx: bp.Context): Promise => { - if (ctx.configurationType === 'manual') { + // TODO: Change null for 'manual' once the Intercom app is approved + if (ctx.configurationType === null) { return new IntercomClient({ tokenAuth: { token: ctx.configuration.accessToken } }) } @@ -42,8 +43,9 @@ const exchangeCodeForAccessToken = async (code: string): Promise => { return accessToken } -export const getSignatureSecret = (ctx: bp.Context): string => { - if (ctx.configurationType === 'manual') { +export const getSignatureSecret = (ctx: bp.Context): string | undefined => { + // TODO: Change null for 'manual' once the Intercom app is approved + if (ctx.configurationType === null) { return ctx.configuration.clientSecret } return bp.secrets.CLIENT_SECRET diff --git a/integrations/intercom/src/index.ts b/integrations/intercom/src/index.ts index 10d7550911f..9717169126d 100644 --- a/integrations/intercom/src/index.ts +++ b/integrations/intercom/src/index.ts @@ -402,7 +402,7 @@ function verifyRequest(req: Request, ctx: bp.Context): VerifyResult { } const signature = extractSignature(req) const secret = getSignatureSecret(ctx) - if (!signature || !isSignatureValid(signature, req.body, secret)) { + if (secret && (!signature || !isSignatureValid(signature, req.body, secret))) { return { result: 'error', isError: true, message: 'Handler received request with invalid signature' } } From 174b906e2548fd6674e06d380907f45245ce528d Mon Sep 17 00:00:00 2001 From: yxL05 Date: Wed, 10 Sep 2025 11:02:29 -0400 Subject: [PATCH 2/3] feat(integrations/loops): added Loops integration (#14198) Co-authored-by: Yang Li --- integrations/loops/definitions/actions.ts | 17 +++ integrations/loops/definitions/events.ts | 59 ++++++++ integrations/loops/definitions/index.ts | 14 ++ integrations/loops/definitions/schemas.ts | 96 +++++++++++++ integrations/loops/hub.md | 5 + integrations/loops/icon.svg | 3 + integrations/loops/integration.definition.ts | 14 ++ integrations/loops/package.json | 17 +++ integrations/loops/src/actions/index.ts | 6 + .../src/actions/send-transactional-email.ts | 35 +++++ .../loops/src/events/email-clicked.ts | 16 +++ .../loops/src/events/email-delivered.ts | 16 +++ .../loops/src/events/email-hard-bounced.ts | 16 +++ integrations/loops/src/events/email-opened.ts | 16 +++ .../loops/src/events/email-soft-bounced.ts | 16 +++ .../loops/src/events/email-spam-reported.ts | 16 +++ .../loops/src/events/email-unsubscribed.ts | 16 +++ integrations/loops/src/events/index.ts | 17 +++ integrations/loops/src/handler.ts | 44 ++++++ integrations/loops/src/index.ts | 18 +++ integrations/loops/src/loops.api.ts | 81 +++++++++++ integrations/loops/src/loops.webhook.ts | 85 +++++++++++ integrations/loops/tsconfig.json | 10 ++ pnpm-lock.yaml | 134 +++++++++++------- 24 files changed, 713 insertions(+), 54 deletions(-) create mode 100644 integrations/loops/definitions/actions.ts create mode 100644 integrations/loops/definitions/events.ts create mode 100644 integrations/loops/definitions/index.ts create mode 100644 integrations/loops/definitions/schemas.ts create mode 100644 integrations/loops/hub.md create mode 100644 integrations/loops/icon.svg create mode 100644 integrations/loops/integration.definition.ts create mode 100644 integrations/loops/package.json create mode 100644 integrations/loops/src/actions/index.ts create mode 100644 integrations/loops/src/actions/send-transactional-email.ts create mode 100644 integrations/loops/src/events/email-clicked.ts create mode 100644 integrations/loops/src/events/email-delivered.ts create mode 100644 integrations/loops/src/events/email-hard-bounced.ts create mode 100644 integrations/loops/src/events/email-opened.ts create mode 100644 integrations/loops/src/events/email-soft-bounced.ts create mode 100644 integrations/loops/src/events/email-spam-reported.ts create mode 100644 integrations/loops/src/events/email-unsubscribed.ts create mode 100644 integrations/loops/src/events/index.ts create mode 100644 integrations/loops/src/handler.ts create mode 100644 integrations/loops/src/index.ts create mode 100644 integrations/loops/src/loops.api.ts create mode 100644 integrations/loops/src/loops.webhook.ts create mode 100644 integrations/loops/tsconfig.json diff --git a/integrations/loops/definitions/actions.ts b/integrations/loops/definitions/actions.ts new file mode 100644 index 00000000000..e7610400ec7 --- /dev/null +++ b/integrations/loops/definitions/actions.ts @@ -0,0 +1,17 @@ +import { ActionDefinition, IntegrationDefinitionProps } from '@botpress/sdk' +import { sendTransactionalEmailInputSchema, sendTransactionalEmailOutputSchema } from './schemas' + +const sendTransactionalEmail = { + title: 'Send Transactional Email', + description: 'Send a transactional email to a client', + input: { + schema: sendTransactionalEmailInputSchema, + }, + output: { + schema: sendTransactionalEmailOutputSchema, + }, +} as const satisfies ActionDefinition + +export const actions = { + sendTransactionalEmail, +} as const satisfies IntegrationDefinitionProps['actions'] diff --git a/integrations/loops/definitions/events.ts b/integrations/loops/definitions/events.ts new file mode 100644 index 00000000000..dad02ac995b --- /dev/null +++ b/integrations/loops/definitions/events.ts @@ -0,0 +1,59 @@ +import { EventDefinition, IntegrationDefinitionProps } from '@botpress/sdk' +import { campaignOrLoopEmailEventSchema, fullEmailEventSchema } from './schemas' + +const emailDelivered = { + title: 'Email Delivered', + description: 'Sent when an email is delivered to its recipient.', + schema: fullEmailEventSchema, +} as const satisfies EventDefinition + +const emailSoftBounced = { + title: 'Email Soft Bounced', + description: + 'Sent when an email soft bounces. Soft bounces are temporary email delivery failures, for example a connection timing out. Soft bounces are retried multiple times and some times the email is delivered.', + schema: fullEmailEventSchema, +} as const satisfies EventDefinition + +const emailHardBounced = { + title: 'Email Hard Bounced', + description: + "Sent when an email hard bounces. Hard bounces are persistent email delivery failures, for example a mailbox that doesn't exist. The email will not be delivered.", + schema: fullEmailEventSchema, +} as const satisfies EventDefinition + +const emailOpened = { + title: 'Email Opened', + description: + 'Sent when a campaign or loop email is opened. This event is not available for transactional emails because email opens are not tracked for transactional emails.', + schema: campaignOrLoopEmailEventSchema, +} as const satisfies EventDefinition + +const emailClicked = { + title: 'Email Clicked', + description: + 'Sent when a link in a campaign or loop email is clicked. This event is not available for transactional emails because link clicks are not tracked in transactional emails.', + schema: campaignOrLoopEmailEventSchema, +} as const satisfies EventDefinition + +const emailUnsubscribed = { + title: 'Email Unsubscribed', + description: + 'Sent when a recipient unsubscribes via the unsubscribe link in an email. This event is not available for transactional emails because unsubscribe links are not included or required for transactional emails.', + schema: campaignOrLoopEmailEventSchema, +} as const satisfies EventDefinition + +const emailSpamReported = { + title: 'Email Spam Reported', + description: 'Sent when a recipient reports your email as spam.', + schema: fullEmailEventSchema, +} as const satisfies EventDefinition + +export const events = { + emailDelivered, + emailSoftBounced, + emailHardBounced, + emailOpened, + emailClicked, + emailUnsubscribed, + emailSpamReported, +} as const satisfies IntegrationDefinitionProps['events'] diff --git a/integrations/loops/definitions/index.ts b/integrations/loops/definitions/index.ts new file mode 100644 index 00000000000..4044a77a345 --- /dev/null +++ b/integrations/loops/definitions/index.ts @@ -0,0 +1,14 @@ +import { ConfigurationDefinition, z } from '@botpress/sdk' + +export { actions } from './actions' +export { events } from './events' + +export const configuration = { + schema: z.object({ + apiKey: z.string().title('API Key').describe('The API key for Loops'), + webhookSigningSecret: z + .string() + .title('Webhook Signing Secret') + .describe('The secret key for verifying incoming Loops webhook events. Must start with "whsec_".'), + }), +} as const satisfies ConfigurationDefinition diff --git a/integrations/loops/definitions/schemas.ts b/integrations/loops/definitions/schemas.ts new file mode 100644 index 00000000000..7677448bb05 --- /dev/null +++ b/integrations/loops/definitions/schemas.ts @@ -0,0 +1,96 @@ +import { z } from '@botpress/sdk' + +export const sendTransactionalEmailInputSchema = z.object({ + email: z.string().describe('The email address of the recipient.').title('Email'), + transactionalId: z.string().describe('The ID of the transactional email to send.').title('Transactional ID'), + dataVariables: z + .array(z.object({ key: z.string(), value: z.string() })) + .describe('An object containing data as defined by the data variables added to the transactional email template.') + .title('Data Variables'), + addToAudience: z + .boolean() + .optional() + .describe( + 'If true, a contact will be created in your audience using the email value (if a matching contact doesn’t already exist).' + ) + .title('Add to Audience'), + idempotencyKey: z + .string() + .optional() + .describe( + 'Optionally send an idempotency key to avoid duplicate requests. The value should be a string of up to 100 characters and should be unique for each request. We recommend using V4 UUIDs or some other method with enough guaranteed entropy to avoid collisions during a 24 hour window. The endpoint will return a 409 Conflict response if the idempotency key has been used in the previous 24 hours.' + ) + .title('Idempotency Key'), +}) + +export const sendTransactionalEmailOutputSchema = z.object({}) + +const _commonEventSchema = z.object({ + eventName: z.string().title('Event Type').describe('The type of event as defined by Loops'), + webhookSchemaVersion: z.string().title('Webhook Schema Version').describe('Will be 1.0.0 for all events'), + eventTime: z.number().title('Event Time').describe('The Unix timestamp of the time the event occurred'), +}) + +const _baseEmailEventSchema = _commonEventSchema.extend({ + contactIdentity: z + .object({ + id: z.string().title('Contact ID').describe('The ID of the contact assigned by Loops'), + email: z.string().title('Contact Email').describe('The email address of the contact'), + userId: z + .string() + .nullable() + .title('Contact User ID') + .describe('The unique user ID created by the contact. May be null'), + }) + .title('Contact Identity') + .describe('The identifiers of the contact. Includes the contact ID, email address, and user ID'), + email: z + .object({ + id: z.string().title('Email ID').describe('The ID of the email'), + emailMessageId: z.string().title('Email Message ID').describe('The ID of the sent version of the email'), + subject: z.string().title('Email Subject').describe('The subject of the sent version of the email'), + }) + .title('Email Details') + .describe( + 'The details about an individual email sent to a recipient. Includes the email ID, the ID of the sent version, and the subject' + ), +}) + +export const campaignOrLoopEmailEventSchema = _baseEmailEventSchema.extend({ + sourceType: z + .enum(['campaign', 'loop']) + .title('Source Type') + .describe('The type of email that triggered the event. One of campaign or loop'), + campaignId: z + .string() + .optional() + .title('Campaign ID') + .describe('The ID of the campaign email. Only one of Campaign ID or Loop ID must exist.'), + loopId: z + .string() + .optional() + .title('Loop ID') + .describe('The ID of the loop email. Only one of Campaign ID or Loop ID must exist.'), +}) + +export const fullEmailEventSchema = _baseEmailEventSchema.extend({ + sourceType: z + .enum(['campaign', 'loop', 'transactional']) + .title('Source Type') + .describe('The type of email that triggered the event. One of campaign, loop, or transactional'), + campaignId: z + .string() + .optional() + .title('Campaign ID') + .describe('The ID of the campaign email. Only one of Campaign ID or Loop ID must exist.'), + loopId: z + .string() + .optional() + .title('Loop ID') + .describe('The ID of the loop email. Only one of Campaign ID or Loop ID must exist.'), + transactionalId: z + .string() + .optional() + .title('Transactional ID') + .describe('The ID of the transactional email. Only one of Campaign ID, Loop ID, or Transactional ID must exist.'), +}) diff --git a/integrations/loops/hub.md b/integrations/loops/hub.md new file mode 100644 index 00000000000..54451a49260 --- /dev/null +++ b/integrations/loops/hub.md @@ -0,0 +1,5 @@ +# Loops Integration + +## Configuration + +- **API Key - Required:** can be retrieved at [Settings > API > Generate API key][https://app.loops.so/settings?page=api]. diff --git a/integrations/loops/icon.svg b/integrations/loops/icon.svg new file mode 100644 index 00000000000..f6b53f0ce01 --- /dev/null +++ b/integrations/loops/icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/integrations/loops/integration.definition.ts b/integrations/loops/integration.definition.ts new file mode 100644 index 00000000000..080d8b022e9 --- /dev/null +++ b/integrations/loops/integration.definition.ts @@ -0,0 +1,14 @@ +import { IntegrationDefinition } from '@botpress/sdk' +import { actions, configuration, events } from './definitions' + +export default new IntegrationDefinition({ + name: 'loops', + title: 'Loops', + description: 'Handle transactional emails from your chatbot.', + version: '0.1.0', + readme: 'hub.md', + icon: 'icon.svg', + configuration, + actions, + events, +}) diff --git a/integrations/loops/package.json b/integrations/loops/package.json new file mode 100644 index 00000000000..027bd02fed7 --- /dev/null +++ b/integrations/loops/package.json @@ -0,0 +1,17 @@ +{ + "name": "@botpresshub/loops", + "description": "Loops integration for Botpress", + "private": true, + "scripts": { + "build": "bp add -y && bp build", + "check:type": "tsc --noEmit", + "check:bplint": "bp lint" + }, + "dependencies": { + "@botpress/common": "workspace:*", + "@botpress/sdk": "workspace:*" + }, + "devDependencies": { + "@botpress/cli": "workspace:*" + } +} diff --git a/integrations/loops/src/actions/index.ts b/integrations/loops/src/actions/index.ts new file mode 100644 index 00000000000..a3f7817acb0 --- /dev/null +++ b/integrations/loops/src/actions/index.ts @@ -0,0 +1,6 @@ +import { sendTransactionalEmail } from './send-transactional-email' +import * as bp from '.botpress' + +export default { + sendTransactionalEmail, +} satisfies bp.IntegrationProps['actions'] diff --git a/integrations/loops/src/actions/send-transactional-email.ts b/integrations/loops/src/actions/send-transactional-email.ts new file mode 100644 index 00000000000..99ec86d48c5 --- /dev/null +++ b/integrations/loops/src/actions/send-transactional-email.ts @@ -0,0 +1,35 @@ +import { LoopsApi } from 'src/loops.api' +import * as bp from '.botpress' + +export const sendTransactionalEmail: bp.IntegrationProps['actions']['sendTransactionalEmail'] = async (props) => { + const logger = props.logger.forBot() + + const { + input: { email, transactionalId, dataVariables: dataVariableEntries, addToAudience, idempotencyKey }, + ctx: { + configuration: { apiKey }, + }, + } = props + + logger.info('This is the data variables:', { dataVariableEntries }) + + const dataVariables = dataVariableEntries?.reduce((acc: Record, item) => { + acc[item.key] = item.value + return acc + }, {}) + + logger.info('This is the parsed data variables for the API request:', { dataVariables }) + + const requestBody = { + email, + transactionalId, + addToAudience, + idempotencyKey, + dataVariables: Object.keys(dataVariables).length > 0 ? dataVariables : undefined, + } + + logger.info('This is the request body:', { requestBody }) + + const loops = new LoopsApi(apiKey, logger) + return await loops.sendTransactionalEmail(requestBody) +} diff --git a/integrations/loops/src/events/email-clicked.ts b/integrations/loops/src/events/email-clicked.ts new file mode 100644 index 00000000000..979b97aefd0 --- /dev/null +++ b/integrations/loops/src/events/email-clicked.ts @@ -0,0 +1,16 @@ +import { WebhookHandlerProps } from '@botpress/sdk/dist/integration' +import { campaignOrLoopEmailEventSchema } from 'definitions/schemas' +import { formatWebhookEventPayload, TValidWebhookEventPayload } from 'src/loops.webhook' +import { TIntegration } from '.botpress' + +export const fireEmailClicked = async ( + client: WebhookHandlerProps['client'], + payload: TValidWebhookEventPayload +): Promise => { + const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema) + + await client.createEvent({ + type: 'emailClicked', + payload: formattedPayload, + }) +} diff --git a/integrations/loops/src/events/email-delivered.ts b/integrations/loops/src/events/email-delivered.ts new file mode 100644 index 00000000000..2d2c1387367 --- /dev/null +++ b/integrations/loops/src/events/email-delivered.ts @@ -0,0 +1,16 @@ +import { WebhookHandlerProps } from '@botpress/sdk/dist/integration' +import { fullEmailEventSchema } from 'definitions/schemas' +import { formatWebhookEventPayload, TValidWebhookEventPayload } from 'src/loops.webhook' +import { TIntegration } from '.botpress' + +export const fireEmailDelivered = async ( + client: WebhookHandlerProps['client'], + payload: TValidWebhookEventPayload +): Promise => { + const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema) + + await client.createEvent({ + type: 'emailDelivered', + payload: formattedPayload, + }) +} diff --git a/integrations/loops/src/events/email-hard-bounced.ts b/integrations/loops/src/events/email-hard-bounced.ts new file mode 100644 index 00000000000..54d282c297b --- /dev/null +++ b/integrations/loops/src/events/email-hard-bounced.ts @@ -0,0 +1,16 @@ +import { WebhookHandlerProps } from '@botpress/sdk/dist/integration' +import { fullEmailEventSchema } from 'definitions/schemas' +import { TValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook' +import { TIntegration } from '.botpress' + +export const fireEmailHardBounced = async ( + client: WebhookHandlerProps['client'], + payload: TValidWebhookEventPayload +): Promise => { + const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema) + + await client.createEvent({ + type: 'emailHardBounced', + payload: formattedPayload, + }) +} diff --git a/integrations/loops/src/events/email-opened.ts b/integrations/loops/src/events/email-opened.ts new file mode 100644 index 00000000000..589bcbea476 --- /dev/null +++ b/integrations/loops/src/events/email-opened.ts @@ -0,0 +1,16 @@ +import { WebhookHandlerProps } from '@botpress/sdk/dist/integration' +import { campaignOrLoopEmailEventSchema } from 'definitions/schemas' +import { TValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook' +import { TIntegration } from '.botpress' + +export const fireEmailOpened = async ( + client: WebhookHandlerProps['client'], + payload: TValidWebhookEventPayload +): Promise => { + const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema) + + await client.createEvent({ + type: 'emailOpened', + payload: formattedPayload, + }) +} diff --git a/integrations/loops/src/events/email-soft-bounced.ts b/integrations/loops/src/events/email-soft-bounced.ts new file mode 100644 index 00000000000..ea1f81f5b37 --- /dev/null +++ b/integrations/loops/src/events/email-soft-bounced.ts @@ -0,0 +1,16 @@ +import { WebhookHandlerProps } from '@botpress/sdk/dist/integration' +import { fullEmailEventSchema } from 'definitions/schemas' +import { TValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook' +import { TIntegration } from '.botpress' + +export const fireEmailSoftBounced = async ( + client: WebhookHandlerProps['client'], + payload: TValidWebhookEventPayload +): Promise => { + const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema) + + await client.createEvent({ + type: 'emailSoftBounced', + payload: formattedPayload, + }) +} diff --git a/integrations/loops/src/events/email-spam-reported.ts b/integrations/loops/src/events/email-spam-reported.ts new file mode 100644 index 00000000000..7e762846e8d --- /dev/null +++ b/integrations/loops/src/events/email-spam-reported.ts @@ -0,0 +1,16 @@ +import { WebhookHandlerProps } from '@botpress/sdk/dist/integration' +import { fullEmailEventSchema } from 'definitions/schemas' +import { TValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook' +import { TIntegration } from '.botpress' + +export const fireEmailSpamReported = async ( + client: WebhookHandlerProps['client'], + payload: TValidWebhookEventPayload +): Promise => { + const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema) + + await client.createEvent({ + type: 'emailSpamReported', + payload: formattedPayload, + }) +} diff --git a/integrations/loops/src/events/email-unsubscribed.ts b/integrations/loops/src/events/email-unsubscribed.ts new file mode 100644 index 00000000000..e2568ff6492 --- /dev/null +++ b/integrations/loops/src/events/email-unsubscribed.ts @@ -0,0 +1,16 @@ +import { WebhookHandlerProps } from '@botpress/sdk/dist/integration' +import { campaignOrLoopEmailEventSchema } from 'definitions/schemas' +import { TValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook' +import { TIntegration } from '.botpress' + +export const fireEmailUnsubscribed = async ( + client: WebhookHandlerProps['client'], + payload: TValidWebhookEventPayload +): Promise => { + const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema) + + await client.createEvent({ + type: 'emailUnsubscribed', + payload: formattedPayload, + }) +} diff --git a/integrations/loops/src/events/index.ts b/integrations/loops/src/events/index.ts new file mode 100644 index 00000000000..73863ca16ec --- /dev/null +++ b/integrations/loops/src/events/index.ts @@ -0,0 +1,17 @@ +import { fireEmailClicked } from './email-clicked' +import { fireEmailDelivered } from './email-delivered' +import { fireEmailHardBounced } from './email-hard-bounced' +import { fireEmailOpened } from './email-opened' +import { fireEmailSoftBounced } from './email-soft-bounced' +import { fireEmailSpamReported } from './email-spam-reported' +import { fireEmailUnsubscribed } from './email-unsubscribed' + +export default { + fireEmailClicked, + fireEmailDelivered, + fireEmailHardBounced, + fireEmailOpened, + fireEmailSoftBounced, + fireEmailSpamReported, + fireEmailUnsubscribed, +} diff --git a/integrations/loops/src/handler.ts b/integrations/loops/src/handler.ts new file mode 100644 index 00000000000..2e8f28e4dc3 --- /dev/null +++ b/integrations/loops/src/handler.ts @@ -0,0 +1,44 @@ +import events from './events' +import { getWebhookEventPayload, verifyWebhookSignature } from './loops.webhook' +import * as bp from '.botpress' + +export const handler: bp.IntegrationProps['handler'] = async (props) => { + props.logger.forBot().info('Handler received request from Loops with request:', props.req) + + verifyWebhookSignature(props) + + const payload = getWebhookEventPayload(props.req.body) + + const client = props.client + + switch (payload.eventName) { + case 'email.delivered': + await events.fireEmailDelivered(client, payload) + break + case 'email.softBounced': + await events.fireEmailSoftBounced(client, payload) + break + case 'email.hardBounced': + await events.fireEmailHardBounced(client, payload) + break + case 'email.opened': + await events.fireEmailOpened(client, payload) + break + case 'email.clicked': + await events.fireEmailClicked(client, payload) + break + case 'email.unsubscribed': + await events.fireEmailUnsubscribed(client, payload) + break + case 'email.spamReported': + await events.fireEmailSpamReported(client, payload) + break + default: + props.logger + .forBot() + .error('Unsupported event type: ' + payload.eventName + ' with payload: ' + JSON.stringify(payload)) + return + } + + props.logger.forBot().info('Event processed successfully with payload:', payload) +} diff --git a/integrations/loops/src/index.ts b/integrations/loops/src/index.ts new file mode 100644 index 00000000000..79d6e5a9d16 --- /dev/null +++ b/integrations/loops/src/index.ts @@ -0,0 +1,18 @@ +import actions from './actions' +import { handler } from './handler' +import { LoopsApi } from './loops.api' +import { validateWebhookSigningSecret } from './loops.webhook' +import * as bp from '.botpress' + +export default new bp.Integration({ + register: async (props) => { + const loops = new LoopsApi(props.ctx.configuration.apiKey, props.logger.forBot()) + await loops.verifyApiKey() + + validateWebhookSigningSecret(props.ctx.configuration.webhookSigningSecret) + }, + unregister: async () => {}, + actions, + channels: {}, + handler, +}) diff --git a/integrations/loops/src/loops.api.ts b/integrations/loops/src/loops.api.ts new file mode 100644 index 00000000000..14cfd98e5cb --- /dev/null +++ b/integrations/loops/src/loops.api.ts @@ -0,0 +1,81 @@ +import { IntegrationLogger, RuntimeError } from '@botpress/sdk' +import axios, { type AxiosInstance } from 'axios' + +const LOOPS_API_BASE_URL = 'https://app.loops.so/api/v1' + +type sendTransactionalEmailRequest = { + email: string + transactionalId: string + dataVariables?: Record + addToAudience?: boolean + idempotencyKey?: string +} + +type sendTransactionalEmailResponse = {} + +export class LoopsApi { + private _axios: AxiosInstance + + public constructor( + apiKey: string, + private _logger: IntegrationLogger + ) { + this._axios = axios.create({ + baseURL: LOOPS_API_BASE_URL, + headers: { Authorization: `Bearer ${apiKey}` }, + }) + } + + public async verifyApiKey(): Promise { + try { + await this._axios.get('/api-key') + this._logger.info('API key verified successfully.') + } catch (error) { + if (axios.isAxiosError(error)) { + if (!error.response) { + throw new RuntimeError('A network error occurred when trying to validate the API key.') + } + + if (error.response.status === 401) { + throw new RuntimeError('Invalid or missing API key.') + } + + throw new RuntimeError('An unexpected error occurred when trying to validate the API key.') + } + } + } + + public async sendTransactionalEmail(req: sendTransactionalEmailRequest): Promise { + try { + await this._axios.post('/transactional', req, { + headers: { + 'Content-Type': 'application/json', + }, + }) + + this._logger.info('Transactional email sent successfully.') + return {} + } catch (error) { + if (axios.isAxiosError(error)) { + if (!error.response) { + this._logger.error('A network error occurred when calling the Loops API:', error) + throw new RuntimeError('A network error occurred when calling the Loops API.') + } + + this._logger.error('An HTTP error occurred when calling the Loops API:', { + code: error.response.status, + ...error.response.data, + }) + + if (error.response.status === 409) { + throw new RuntimeError('The same idempotency key has already been used in the previous 24 hours.') + } + + throw new RuntimeError('An HTTP error occurred when calling the Loops API.') + } + + this._logger.error('An unexpected error occurred when calling the Loops API:', error) + throw new RuntimeError('An unexpected error occurred when calling the Loops API, see logs for more information.') + } + } +} diff --git a/integrations/loops/src/loops.webhook.ts b/integrations/loops/src/loops.webhook.ts new file mode 100644 index 00000000000..f36c885ba6a --- /dev/null +++ b/integrations/loops/src/loops.webhook.ts @@ -0,0 +1,85 @@ +import { RuntimeError, z } from '@botpress/sdk' +import { WebhookHandlerProps } from '@botpress/sdk/dist/integration' +import crypto from 'crypto' +import { TIntegration } from '.botpress' + +export type TValidWebhookEventPayload = { eventName: string } + +export function validateWebhookSigningSecret(value: string): void { + if (!value || !value.startsWith('whsec_')) { + throw new RuntimeError('Webhook signing secret must start with "whsec_"') + } + + if (value.includes(' ')) { + throw new RuntimeError('Webhook signing secret must not contain spaces') + } + + if (!value.split('_')[1]) { + throw new RuntimeError('Secret must not be empty after "whsec_"') + } +} + +export const verifyWebhookSignature = (props: WebhookHandlerProps): void => { + const headers = props.req.headers + + const eventId = headers['webhook-id'] + const timestamp = headers['webhook-timestamp'] + const webhookSignature = headers['webhook-signature'] + + if (!eventId || !timestamp || !webhookSignature) { + throw new RuntimeError('Webhook request is missing required headers') + } + + if (!props.req.body) { + throw new RuntimeError('Webhook request is missing body') + } + + const signedContent = `${eventId}.${timestamp}.${props.req.body}` + + const secret = props.ctx.configuration.webhookSigningSecret + const secretBytes = Buffer.from(secret.split('_')[1]!, 'base64') + + const signature = crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64') + + const signatureFound = webhookSignature.split(' ').some((sig) => sig.includes(`,${signature}`)) + if (!signatureFound) { + throw new RuntimeError('Webhook signature is invalid') + } + + props.logger.forBot().info('Webhook signature of incoming request verified successfully') +} + +export const getWebhookEventPayload = ( + body: WebhookHandlerProps['req']['body'] +): TValidWebhookEventPayload => { + if (!body) { + throw new RuntimeError('Webhook request is missing body') + } + + try { + const payload = JSON.parse(body) + + if (!payload.hasOwnProperty('eventName')) { + throw new RuntimeError('Webhook request is missing the event name') + } + + return payload + } catch { + throw new RuntimeError('Webhook request has an invalid JSON body') + } +} + +export const formatWebhookEventPayload = ( + payload: TValidWebhookEventPayload, + targetSchema: z.ZodSchema +): z.infer => { + const formattedPayload = targetSchema.safeParse(payload) + + if (!formattedPayload.success) { + throw new RuntimeError( + `The payload of this webhook event does not match the expected schema of an event of type ${payload.eventName}` + ) + } + + return formattedPayload.data +} diff --git a/integrations/loops/tsconfig.json b/integrations/loops/tsconfig.json new file mode 100644 index 00000000000..c998946e1dd --- /dev/null +++ b/integrations/loops/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "integration.definition.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce4dd892460..9df1146c94e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1139,6 +1139,19 @@ importers: specifier: ^2.39.1 version: 2.39.1 + integrations/loops: + dependencies: + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + devDependencies: + '@botpress/cli': + specifier: workspace:* + version: link:../../packages/cli + integrations/mailchimp: dependencies: '@botpress/client': @@ -3242,8 +3255,8 @@ packages: resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.0': - resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==} + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} engines: {node: '>=6.9.0'} '@babel/helper-annotate-as-pure@7.27.1': @@ -3292,8 +3305,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.27.3': - resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==} + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -3344,8 +3357,8 @@ packages: resolution: {integrity: sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.27.6': - resolution: {integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==} + '@babel/helpers@7.28.3': + resolution: {integrity: sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==} engines: {node: '>=6.9.0'} '@babel/parser@7.26.9': @@ -3358,8 +3371,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.28.0': - resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} + '@babel/parser@7.28.3': + resolution: {integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==} engines: {node: '>=6.0.0'} hasBin: true @@ -3484,8 +3497,8 @@ packages: resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.0': - resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==} + '@babel/traverse@7.28.3': + resolution: {integrity: sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==} engines: {node: '>=6.9.0'} '@babel/types@7.26.9': @@ -3496,8 +3509,8 @@ packages: resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.1': - resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} + '@babel/types@7.28.2': + resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} '@bcherny/json-schema-ref-parser@10.0.5-fork': @@ -4323,8 +4336,8 @@ packages: resolution: {integrity: sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.12': - resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} @@ -4344,12 +4357,18 @@ packages: '@jridgewell/sourcemap-codec@1.5.4': resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@jridgewell/trace-mapping@0.3.30': + resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -6265,8 +6284,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - browserslist@4.25.1: - resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==} + browserslist@4.25.4: + resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -6375,8 +6394,8 @@ packages: caniuse-lite@1.0.30001700: resolution: {integrity: sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==} - caniuse-lite@1.0.30001727: - resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==} + caniuse-lite@1.0.30001739: + resolution: {integrity: sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==} capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -6890,8 +6909,8 @@ packages: electron-to-chromium@1.5.104: resolution: {integrity: sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g==} - electron-to-chromium@1.5.188: - resolution: {integrity: sha512-pfEx5CBFAocOKNrc+i5fSvhDaI1Vr9R9aT5uX1IzM3hhdL6k649wfuUcdUd9EZnmbE1xdfA51CwqQ61CO3Xl3g==} + electron-to-chromium@1.5.214: + resolution: {integrity: sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -11954,14 +11973,14 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.0 + '@babel/generator': 7.28.3 '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) - '@babel/helpers': 7.27.6 - '@babel/parser': 7.28.0 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.0) + '@babel/helpers': 7.28.3 + '@babel/parser': 7.28.3 '@babel/template': 7.27.2 - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/traverse': 7.28.3 + '@babel/types': 7.28.2 convert-source-map: 2.0.0 debug: 4.4.1 gensync: 1.0.0-beta.2 @@ -11986,12 +12005,12 @@ snapshots: '@jridgewell/trace-mapping': 0.3.29 jsesc: 3.1.0 - '@babel/generator@7.28.0': + '@babel/generator@7.28.3': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@babel/parser': 7.28.3 + '@babel/types': 7.28.2 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.27.1': @@ -12010,7 +12029,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.1 + browserslist: 4.25.4 lru-cache: 5.1.1 semver: 6.3.1 @@ -12068,12 +12087,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)': + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.0 + '@babel/traverse': 7.28.3 transitivePeerDependencies: - supports-color @@ -12116,10 +12135,10 @@ snapshots: '@babel/template': 7.26.9 '@babel/types': 7.26.9 - '@babel/helpers@7.27.6': + '@babel/helpers@7.28.3': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.1 + '@babel/types': 7.28.2 '@babel/parser@7.26.9': dependencies: @@ -12129,9 +12148,9 @@ snapshots: dependencies: '@babel/types': 7.27.1 - '@babel/parser@7.28.0': + '@babel/parser@7.28.3': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.2 '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.9)': dependencies: @@ -12286,14 +12305,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.28.0': + '@babel/traverse@7.28.3': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.0 + '@babel/generator': 7.28.3 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.0 + '@babel/parser': 7.28.3 '@babel/template': 7.27.2 - '@babel/types': 7.28.1 + '@babel/types': 7.28.2 debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -12308,7 +12327,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@babel/types@7.28.1': + '@babel/types@7.28.2': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 @@ -13071,10 +13090,10 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.12': + '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.4 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.30 '@jridgewell/gen-mapping@0.3.8': dependencies: @@ -13090,6 +13109,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.4': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -13100,6 +13121,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/trace-mapping@0.3.30': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -15672,12 +15698,12 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.2(browserslist@4.24.4) - browserslist@4.25.1: + browserslist@4.25.4: dependencies: - caniuse-lite: 1.0.30001727 - electron-to-chromium: 1.5.188 + caniuse-lite: 1.0.30001739 + electron-to-chromium: 1.5.214 node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.25.1) + update-browserslist-db: 1.1.3(browserslist@4.25.4) bs-logger@0.2.6: dependencies: @@ -15789,7 +15815,7 @@ snapshots: caniuse-lite@1.0.30001700: {} - caniuse-lite@1.0.30001727: {} + caniuse-lite@1.0.30001739: {} capital-case@1.0.4: dependencies: @@ -16269,7 +16295,7 @@ snapshots: electron-to-chromium@1.5.104: {} - electron-to-chromium@1.5.188: {} + electron-to-chromium@1.5.214: {} emittery@0.13.1: {} @@ -20791,9 +20817,9 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-browserslist-db@1.1.3(browserslist@4.25.1): + update-browserslist-db@1.1.3(browserslist@4.25.4): dependencies: - browserslist: 4.25.1 + browserslist: 4.25.4 escalade: 3.2.0 picocolors: 1.1.1 From 7c4f259089b495a436a23a8b04828b98333a38b9 Mon Sep 17 00:00:00 2001 From: yxL05 Date: Wed, 10 Sep 2025 13:29:22 -0400 Subject: [PATCH 3/3] fix(integrations/loops): include idempotency key in headers instead of request body (#14225) Co-authored-by: Yang Li --- integrations/loops/src/loops.api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integrations/loops/src/loops.api.ts b/integrations/loops/src/loops.api.ts index 14cfd98e5cb..be0510a7b89 100644 --- a/integrations/loops/src/loops.api.ts +++ b/integrations/loops/src/loops.api.ts @@ -46,10 +46,12 @@ export class LoopsApi { } public async sendTransactionalEmail(req: sendTransactionalEmailRequest): Promise { + const { idempotencyKey, ...reqBody } = req try { - await this._axios.post('/transactional', req, { + await this._axios.post('/transactional', reqBody, { headers: { 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, }, })