chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { sendTransactionalEmail } from './send-transactional-email'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
sendTransactionalEmail,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,154 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import axios from 'axios'
|
||||
import { LoopsApi, TransactionalEmailAttachment } from 'src/loops.api'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const _isValidBase64 = (str: string): boolean => {
|
||||
try {
|
||||
// Check if the string contains only valid base64 characters
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/
|
||||
if (!base64Regex.test(str)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the string length is a multiple of 4 (base64 requirement)
|
||||
if (str.length % 4 !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to decode and re-encode to verify it's valid base64
|
||||
const decoded = Buffer.from(str, 'base64')
|
||||
const reencoded = decoded.toString('base64')
|
||||
|
||||
// Remove padding for comparison since it can vary
|
||||
const normalizedOriginal = str.replace(/=+$/, '')
|
||||
const normalizedReencoded = reencoded.replace(/=+$/, '')
|
||||
|
||||
return normalizedOriginal === normalizedReencoded
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const _encodeFileContentFromUrl = async (url: string, logger: bp.Logger): Promise<string> => {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
})
|
||||
|
||||
return Buffer.from(response.data).toString('base64')
|
||||
} catch (error) {
|
||||
logger.error('An error occurred when trying to get file content from URL:', error)
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (!error.response) {
|
||||
throw new RuntimeError('A network error occurred when trying to get file content from URL.')
|
||||
}
|
||||
|
||||
throw new RuntimeError('An HTTP error occurred when trying to get file content from URL.')
|
||||
}
|
||||
|
||||
throw new RuntimeError('An unexpected error occurred when trying to get file content from URL.')
|
||||
}
|
||||
}
|
||||
|
||||
const _getAttachmentsByFileIds = async (
|
||||
fileIds: string[],
|
||||
client: bp.Client,
|
||||
logger: bp.Logger
|
||||
): Promise<TransactionalEmailAttachment[]> => {
|
||||
logger.info('These are the file IDs:', { fileIds })
|
||||
|
||||
let files
|
||||
try {
|
||||
files = await Promise.all(fileIds.map(async (fileId) => client.getFile({ id: fileId })))
|
||||
} catch (error) {
|
||||
logger.error('An error occurred when getting the files from the Files API:', error)
|
||||
throw new RuntimeError('An error occurred when getting the files from the Files API.')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'This is information about the files returned by the Files API:',
|
||||
files.map(({ file }) => file)
|
||||
)
|
||||
|
||||
const fileAttachments = await Promise.all(
|
||||
files.map(async ({ file }) => {
|
||||
if (!file.size) {
|
||||
throw new RuntimeError('File must be uploaded before it can be attached to an email.')
|
||||
}
|
||||
|
||||
return {
|
||||
filename: file.key,
|
||||
contentType: file.contentType,
|
||||
data: await _encodeFileContentFromUrl(file.url, logger),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return fileAttachments
|
||||
}
|
||||
|
||||
export const sendTransactionalEmail: bp.IntegrationProps['actions']['sendTransactionalEmail'] = async (props) => {
|
||||
const logger = props.logger.forBot()
|
||||
|
||||
const {
|
||||
input: {
|
||||
email,
|
||||
transactionalId,
|
||||
dataVariables: dataVariableEntries,
|
||||
addToAudience,
|
||||
idempotencyKey,
|
||||
fileIds,
|
||||
fileData,
|
||||
},
|
||||
ctx: {
|
||||
configuration: { apiKey },
|
||||
},
|
||||
client,
|
||||
} = props
|
||||
|
||||
logger.info('This is the data variables:', { dataVariableEntries })
|
||||
|
||||
const dataVariables = dataVariableEntries?.reduce((acc: Record<string, string>, item) => {
|
||||
if (!item.key || !item.value) {
|
||||
throw new RuntimeError('Required fields are missing from the data variables.')
|
||||
}
|
||||
|
||||
acc[item.key] = item.value
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
logger.info('This is the parsed data variables for the API request:', { dataVariables })
|
||||
|
||||
const attachments: TransactionalEmailAttachment[] = []
|
||||
|
||||
if (fileIds && fileIds.length > 0) {
|
||||
attachments.push(...(await _getAttachmentsByFileIds(fileIds, client, logger)))
|
||||
}
|
||||
|
||||
if (fileData) {
|
||||
fileData.forEach((file) => {
|
||||
if (!file.filename || !file.contentType || !file.data) {
|
||||
throw new RuntimeError('Required fields are missing from the file data.')
|
||||
}
|
||||
|
||||
if (!_isValidBase64(file.data)) {
|
||||
throw new RuntimeError('The encoded data is not a valid base64 string.')
|
||||
}
|
||||
attachments.push(file)
|
||||
})
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
email,
|
||||
transactionalId,
|
||||
addToAudience,
|
||||
idempotencyKey,
|
||||
dataVariables: Object.keys(dataVariables).length > 0 ? dataVariables : undefined,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
}
|
||||
|
||||
const loops = new LoopsApi(apiKey, logger)
|
||||
return await loops.sendTransactionalEmail(requestBody)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
|
||||
import { campaignOrLoopEmailEventSchema } from 'definitions/schemas'
|
||||
import { formatWebhookEventPayload, ValidWebhookEventPayload } from 'src/loops.webhook'
|
||||
import { TIntegration } from '.botpress'
|
||||
|
||||
export const fireEmailClicked = async (
|
||||
client: WebhookHandlerProps<TIntegration>['client'],
|
||||
payload: ValidWebhookEventPayload
|
||||
): Promise<void> => {
|
||||
const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'emailClicked',
|
||||
payload: formattedPayload,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
|
||||
import { fullEmailEventSchema } from 'definitions/schemas'
|
||||
import { formatWebhookEventPayload, ValidWebhookEventPayload } from 'src/loops.webhook'
|
||||
import { TIntegration } from '.botpress'
|
||||
|
||||
export const fireEmailDelivered = async (
|
||||
client: WebhookHandlerProps<TIntegration>['client'],
|
||||
payload: ValidWebhookEventPayload
|
||||
): Promise<void> => {
|
||||
const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'emailDelivered',
|
||||
payload: formattedPayload,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
|
||||
import { fullEmailEventSchema } from 'definitions/schemas'
|
||||
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
|
||||
import { TIntegration } from '.botpress'
|
||||
|
||||
export const fireEmailHardBounced = async (
|
||||
client: WebhookHandlerProps<TIntegration>['client'],
|
||||
payload: ValidWebhookEventPayload
|
||||
): Promise<void> => {
|
||||
const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'emailHardBounced',
|
||||
payload: formattedPayload,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
|
||||
import { campaignOrLoopEmailEventSchema } from 'definitions/schemas'
|
||||
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
|
||||
import { TIntegration } from '.botpress'
|
||||
|
||||
export const fireEmailOpened = async (
|
||||
client: WebhookHandlerProps<TIntegration>['client'],
|
||||
payload: ValidWebhookEventPayload
|
||||
): Promise<void> => {
|
||||
const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'emailOpened',
|
||||
payload: formattedPayload,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
|
||||
import { fullEmailEventSchema } from 'definitions/schemas'
|
||||
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
|
||||
import { TIntegration } from '.botpress'
|
||||
|
||||
export const fireEmailSoftBounced = async (
|
||||
client: WebhookHandlerProps<TIntegration>['client'],
|
||||
payload: ValidWebhookEventPayload
|
||||
): Promise<void> => {
|
||||
const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'emailSoftBounced',
|
||||
payload: formattedPayload,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
|
||||
import { fullEmailEventSchema } from 'definitions/schemas'
|
||||
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
|
||||
import { TIntegration } from '.botpress'
|
||||
|
||||
export const fireEmailSpamReported = async (
|
||||
client: WebhookHandlerProps<TIntegration>['client'],
|
||||
payload: ValidWebhookEventPayload
|
||||
): Promise<void> => {
|
||||
const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'emailSpamReported',
|
||||
payload: formattedPayload,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
|
||||
import { campaignOrLoopEmailEventSchema } from 'definitions/schemas'
|
||||
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
|
||||
import { TIntegration } from '.botpress'
|
||||
|
||||
export const fireEmailUnsubscribed = async (
|
||||
client: WebhookHandlerProps<TIntegration>['client'],
|
||||
payload: ValidWebhookEventPayload
|
||||
): Promise<void> => {
|
||||
const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'emailUnsubscribed',
|
||||
payload: formattedPayload,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { IntegrationLogger, RuntimeError } from '@botpress/sdk'
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
|
||||
const LOOPS_API_BASE_URL = 'https://app.loops.so/api/v1'
|
||||
|
||||
export type TransactionalEmailAttachment = {
|
||||
filename: string
|
||||
contentType: string
|
||||
data: string
|
||||
}
|
||||
|
||||
type SendTransactionalEmailRequest = {
|
||||
email: string
|
||||
transactionalId: string
|
||||
dataVariables?: Record<string, string>
|
||||
addToAudience?: boolean
|
||||
idempotencyKey?: string
|
||||
attachments?: TransactionalEmailAttachment[]
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<SendTransactionalEmailResponse> {
|
||||
const { idempotencyKey, ...reqBody } = req
|
||||
|
||||
if (idempotencyKey && idempotencyKey.length > 100) {
|
||||
throw new RuntimeError('Idempotency key must be less than 100 characters.')
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': idempotencyKey,
|
||||
}
|
||||
|
||||
this._logger.info('These are the headers of the Loops API request:', headers)
|
||||
|
||||
const { attachments, ...rest } = reqBody
|
||||
this._logger.info('This is the request body of the Loops API request:', rest)
|
||||
this._logger.info('These are the attachments of the Loops API request:', attachments)
|
||||
|
||||
try {
|
||||
await this._axios.post('/transactional', reqBody, { headers })
|
||||
|
||||
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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ValidWebhookEventPayload = { 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<TIntegration>): 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<TIntegration>['req']['body']
|
||||
): ValidWebhookEventPayload => {
|
||||
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: ValidWebhookEventPayload,
|
||||
targetSchema: z.ZodSchema
|
||||
): z.infer<typeof targetSchema> => {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user