chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,27 @@
import { z } from '@botpress/sdk'
import { EMAIL_ADDRESS_DESCRIPTION, EmailAddressSchema, NonBlankString } from './common'
/** The common send email input schema which will be exposed to users in Botpress Studio */
export const sendMailInputSchema = z.object({
from: EmailAddressSchema.describe(EMAIL_ADDRESS_DESCRIPTION).title('Email Sender'),
to: EmailAddressSchema.describe(EMAIL_ADDRESS_DESCRIPTION).title('Email Recipient'),
cc: z.array(EmailAddressSchema).optional().describe('List of carbon copy recipients').title('Carbon Copy'),
bcc: z
.array(EmailAddressSchema)
.optional()
.describe('List of blind carbon copy recipients')
.title('Blind Carbon Copy'),
subject: NonBlankString.describe('The subject of the email (e.g. How to build a bot with Botpress!)').title(
'Email Subject'
),
body: NonBlankString.describe('The markdown rich-text body of the email').title('Email Body'), // TODO: Think of an example to place here
replyTo: EmailAddressSchema.describe(EMAIL_ADDRESS_DESCRIPTION).title('Reply To').optional(),
})
/** The response schema for SendGrid's send email endpoint for successful requests. */
export const sendEmailOutputSchema = z.object({
/** The output is empty because in the current SendGrid API
* version, nothing is returned when the request is successful.
*
* Observed: 2025-06-24 */
})
@@ -0,0 +1,18 @@
import { z } from '@botpress/sdk'
/** A string that must contain at least 1 non-whitespace character.
*
* @remark This can still be an optional field */
export const NonBlankString = z.string().trim().min(1)
/** A common description that should apply to all Email Address Fields.
*
* @remark Using this constant so it can be re-used in multiple other schemas.
* This is because the linter doesn't detect the "describe" if I were to bind
* it to the "EmailAddressSchema" definition */
export const EMAIL_ADDRESS_DESCRIPTION = 'The email address of the correspondent (e.g. example@example.com)'
/** A string that has been verified to be a valid email address.
*
* @remark "correspondent" can refer to both the sender and the receiver of an email. */
export const EmailAddressSchema = NonBlankString.email()
@@ -0,0 +1,63 @@
import { z } from '@botpress/sdk'
import { EmailAddressSchema } from './common'
const _webhookEmailEventSchema = z.object({
eventId: z.string().describe('The ID for the webhook event').title('Webhook event ID'),
/** 'messageId' will be present in almost every case where the event is tied to a sent email
* (So account status changed webhook events won't have one). The only exception to a sent
* email webhook event not having a message id is for "[Asynchronous Bounces](https://www.twilio.com/docs/sendgrid/ui/sending-email/bounces#asynchronous-bounces)". */
messageId: z.string().optional().describe('The ID for the sent email message').title('Email message ID'),
timestamp: z
.string()
.datetime()
.describe('A UTC datetime representing when the event was triggered')
.title('Event timestamp'),
})
export const processedEmailEventSchema = _webhookEmailEventSchema.extend({
sendAt: z
.string()
.datetime()
.describe('A UTC datetime representing when the email is scheduled to be sent at')
.title('Scheduled send timestamp'),
})
export const deferredEmailEventSchema = _webhookEmailEventSchema.extend({
attempt: z
.string()
.describe('The delivery attempts that have been made for the sent email')
.title('Delivery attempts'),
})
export const deliveredEmailEventSchema = _webhookEmailEventSchema.extend({
email: z.string().describe('The designated recipient of the email').title('Email recipient'),
})
// So far this has only been seen in Bounce events, but I will check the other error events
// to see if they also contain this. Otherwise, I will merge into 'BouncedEmailEventPayload'
const _emailErrorEventSchema = _webhookEmailEventSchema.extend({
reason: z.string().optional().describe('The reason this event was triggered').title('Event reason'),
})
export const bouncedEmailEventSchema = _emailErrorEventSchema.extend({
classification: z.string().describe('The SendGrid classification for the bounce').title('Bounce classification'),
type: z
.string()
.describe('The SendGrid type for why the email bounced (e.g. "bounce", "blocked", etc.)')
.title('Bounce type'),
})
export const openedEmailEventSchema = _emailErrorEventSchema.extend({
email: EmailAddressSchema.describe('The designated recipient of the email').title('Email recipient'),
})
export const clickedEmailLinkEventSchema = _emailErrorEventSchema.extend({
email: EmailAddressSchema.describe('The designated recipient of the email').title('Email recipient'),
url: z.string().describe('The destination URL of the link that was clicked').title('Clicked URL'),
urlOffset: z
.number()
.describe(
'A zero-based index, ordered by first appearance, of which link was clicked in an email when it shares a "url" with another link'
)
.title('URL offset'),
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+57
View File
@@ -0,0 +1,57 @@
# SendGrid Integration
## Overview
`@botpresshub/sendgrid` is an integration that allows a Botpress chatbot to send emails via the SendGrid API.
## Configuration
### Authenticating your domain (https://app.sendgrid.com/settings/sender_auth)
1. Login to the SendGrid dashboard (https://app.sendgrid.com/)
2. In the navigation bar on the left, open "Settings" submenu, click on "Sender Authentication"
3. In the center-right part of the screen, click the "Get Started" button (First domain authentication only)
- (2nd+ domain auth) Near the lower left in the "Domain Authentication" section, click "Authenticate Your Domain"
4. Select the provider for your domain, if the provider is not listed, pick "I'm Not Sure" (It's easier than "Other host" option)
- Also tested with SquareSpace Domains
5. In the "From Domain" field, enter your domain (e.g. "Botpress.com")
6. Add the DNS records in the portal of your domain provider (e.g. Cloudflare, GoDaddy, SquareSpace, etc.)
7. Check "I've added these records" & click "Verify" and wait for SendGrid to confirm it's been verified.
- If an error is still shown in spite of correctly adding the 'CNAME' DNS records to your domain provider, try removing the domain suffix from the host key. (e.g. "em1234.botpress.com" > "em1234")
8. Now you're ready to send emails with your domain. Happy Emailing!
### Acquiring an API key (https://app.sendgrid.com/settings/api_keys)
1. Login to the SendGrid dashboard (https://app.sendgrid.com/)
2. In the navigation bar on the left, open "Settings" submenu, click on "API Keys"
3. Near the top-right, click "Create API Key"
4. Give the key a name
5. Grant the key permissions with either "Full Access" or if using "Restricted Access" select the following:
- "Mail Send" (Full Access)
6. Click "Create & View"
### Setting up Webhooks (https://app.sendgrid.com/settings/mail_settings/webhook_settings)
1. Login to the SendGrid dashboard (https://app.sendgrid.com/)
2. In the navigation bar on the left, open "Settings" submenu, click on "Mail Settings"
3. In Mail Settings, click on "Event Webhooks"
4. In the center-right part of the screen, click "Create new webhook"
5. (Optional) Give the webhook a "Friendly Name" (e.g. "Botpress Bot")
6. Copy the webhook URL from the Botpress integration config & paste it into Sendgrid's "Post URL" field
7. Select the "Actions to be posted", these will be the events that the Botpress integration will receive.
1. Note: This integration doesn't support all the events yet
8. (Optional, but recommended) Enable "Signature Verification"
1. If you've enabled this, once the webhook is saved, click the cog on the webhook and click "edit"
2. Copy the "Verification key" from Sendgrid and paste it into the "Webhook Verification Key" field of the Botpress integration config
9. Click "Save" and you're ready to use the events in your Botpress bot
## Side Notes
The current implementation is limited to only sending [markdown](https://spec.commonmark.org/0.31.2/) rich text emails, though this will be expanded upon in the future.
## Resources
- https://www.twilio.com/docs/sendgrid/for-developers
- https://www.twilio.com/docs/sendgrid/api-reference
- https://www.twilio.com/docs/sendgrid/ui/account-and-settings/api-keys
- https://www.twilio.com/docs/sendgrid/ui/account-and-settings/how-to-set-up-domain-authentication
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Sendgrid" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<style>
.cls-1 {
fill: #51a9e3;
}
</style>
</defs>
<g id="Icon_Updated_Keylines" data-name="Icon, Updated Keylines">
<path class="cls-1" d="m21,1h-8c-1.1,0-2,.9-2,2v6c0,1.1.9,2,2,2h6c1.1,0,2,.9,2,2v6c0,1.1.9,2,2,2h6c1.1,0,2-.9,2-2V2c0-.55-.45-1-1-1h-9Z"/>
<path class="cls-1" d="m11,31h8c1.1,0,2-.9,2-2v-6c0-1.1-.9-2-2-2h-6c-1.1,0-2-.9-2-2v-6c0-1.1-.9-2-2-2H3c-1.1,0-2,.9-2,2v17c0,.55.45,1,1,1h9Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 617 B

@@ -0,0 +1,88 @@
import { z, IntegrationDefinition } from '@botpress/sdk'
import { sendEmailOutputSchema, sendMailInputSchema } from './definitions/actions'
import {
bouncedEmailEventSchema,
clickedEmailLinkEventSchema,
deferredEmailEventSchema,
deliveredEmailEventSchema,
openedEmailEventSchema,
processedEmailEventSchema,
} from './definitions/events'
export default new IntegrationDefinition({
name: 'sendgrid',
title: 'SendGrid',
version: '0.1.10',
readme: 'hub.md',
icon: 'icon.svg',
description: 'Send markdown rich-text emails using the SendGrid email service.',
configuration: {
schema: z.object({
apiKey: z.string().secret().min(1).describe('Your SendGrid API Key').title('SendGrid API Key'),
publicSignatureKey: z
.string()
.secret()
.optional()
.describe(
'The public key used to verify the cryptographic signature of the webhook requests (Found in the edit webhook menu)'
)
.title('Webhook Verification Key'),
}),
},
actions: {
sendMail: {
title: 'Send Email',
description: 'Sends an email to the specified client',
input: {
schema: sendMailInputSchema,
},
output: {
schema: sendEmailOutputSchema,
},
},
},
events: {
delivered: {
title: 'Email Delivered',
description:
"An event that triggers when the SendGrid API delivers a given email to the recipient's email server. (This can also trigger alongside email bounces among other events)",
schema: deliveredEmailEventSchema,
},
bounced: {
title: 'Email Bounced',
description:
'An event that triggers when an email sent via the SendGrid API bounces. (e.g. Invalid Address, blocked, etc)',
schema: bouncedEmailEventSchema,
},
deferred: {
title: 'Email Deferred',
description:
"An event that triggers when the SendGrid API fails a delivery attempt to the recipient's email server. (This will often re-attempt a few times before stopping)",
schema: deferredEmailEventSchema,
},
processed: {
title: 'Email Processed',
description: 'An event that triggers when the SendGrid API has processed an outbound email.',
schema: processedEmailEventSchema,
},
opened: {
title: 'Email Opened',
description:
"An event that triggers when the SendGrid API detects that an email has been opened by the recipient. (Must have 'Open Tracking' enabled)\n\nNote: This may be subject to privacy regulations of the email recipient's country",
schema: openedEmailEventSchema,
},
clicked: {
title: 'Email Link Clicked',
description:
"An event that triggers when the SendGrid API detects that a link in the email has been clicked on by the recipient.(Must have 'Click Tracking' enabled)\n\nNote: This may be subject to privacy regulations of the email recipient's country",
schema: clickedEmailLinkEventSchema,
},
},
__advanced: {
useLegacyZuiTransformer: true,
},
attributes: {
category: 'Marketing & Email',
repo: 'botpress',
},
})
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@botpresshub/sendgrid",
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"@sendgrid/client": "^8.1.5",
"@sendgrid/eventwebhook": "^8.0.0",
"@sendgrid/helpers": "^8.0.0",
"@sendgrid/mail": "^8.1.5",
"markdown-it": "^14.1.0"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*",
"@types/markdown-it": "^14.1.2"
}
}
+15
View File
@@ -0,0 +1,15 @@
This document includes resources used to build the
integration and may be helpful in it's maintenance
Brand icons:
- https://sendgrid.com/en-us/resource/brand
Documentation:
- [Developer Documentation] https://www.twilio.com/docs/sendgrid/for-developers
- [OpenAPI Spec] https://github.com/twilio/sendgrid-oai/tree/main/spec
Packages:
- https://www.npmjs.com/package/@sendgrid/mail
- https://www.npmjs.com/package/@sendgrid/client
- https://www.npmjs.com/package/@sendgrid/helpers
@@ -0,0 +1,5 @@
import { sendMail } from './send-mail'
export default {
sendMail,
}
@@ -0,0 +1,34 @@
import { RuntimeError } from '@botpress/sdk'
import { markdownToHtml } from '../misc/markdown-utils'
import { SendGridClient } from '../misc/sendgrid-api'
import { parseError } from '../misc/utils'
import * as bp from '.botpress'
export const sendMail: bp.IntegrationProps['actions']['sendMail'] = async ({ ctx, input, logger }) => {
try {
const httpClient = new SendGridClient(ctx.configuration.apiKey)
const response = await httpClient.sendMail({
personalizations: [
{
to: input.to,
cc: input.cc,
bcc: input.bcc,
},
],
from: input.from,
replyTo: input.replyTo,
subject: input.subject,
html: markdownToHtml(input.body),
})
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new RuntimeError('Failed to send email.')
}
return {}
} catch (thrown: unknown) {
const error = parseError(ctx, thrown)
logger.forBot().error('Failed to send email', error)
throw error
}
}
+51
View File
@@ -0,0 +1,51 @@
import actions from './actions'
import { SendGridClient } from './misc/sendgrid-api'
import { parseError } from './misc/utils'
import { parseWebhookData, verifyWebhookSignature } from './misc/webhook-utils'
import { dispatchIntegrationEvent } from './webhook-events/event-dispatcher'
import { webhookEventPayloadSchemas } from './webhook-events/schemas'
import * as bp from '.botpress'
export default new bp.Integration({
register: async ({ ctx }) => {
try {
const httpClient = new SendGridClient(ctx.configuration.apiKey)
const response = await httpClient.getPermissionScopes()
if (response && (response.statusCode < 200 || response.statusCode >= 300)) {
throw new Error(`The status code '${response.statusCode}' is not within the accepted bounds.`)
}
} catch (thrown: unknown) {
throw parseError(ctx, thrown, 'An invalid API key was provided')
}
},
unregister: async () => {},
actions,
channels: {},
handler: async (props) => {
const data = parseWebhookData(props)
if (data === null) return
if (data.publicKey !== null && !verifyWebhookSignature(data)) {
props.logger.forBot().error('The provided SendGrid webhook public signature key is invalid')
return
}
if (!Array.isArray(data.body)) {
return
}
for (const item of data.body) {
// This approach is a bit stinky. However, it's the only reliable way I could think of to not
// have unhandled webhook events crash the handler when they can also come in with valid events
// (Using ZodArray outside the loop can cause the aforementioned issue)
const result = webhookEventPayloadSchemas.safeParse(item)
if (!result.success) {
props.logger.error('Unable to parse sendgrid webhook event', result.error, item)
continue
}
await dispatchIntegrationEvent(props, result.data)
}
},
})
@@ -0,0 +1,47 @@
import type { ResponseError } from '@sendgrid/helpers/classes'
// ============ Common Types ============
/** A type for modifying the structure of another type */
export type Merge<T, R> = Omit<T, keyof R> & R
// ============ Send Grid ============
/** An overridden type of the SendGrid 'ResponseError'
* because the response body is not always a string type.
*
* @remark While it may still be possible for the body
* to be a string, the validation check I'm doing in
* 'utils.ts' asserts that the body is an object &
* that it contains a property called "errors". */
export type SendGridResponseError = Merge<
ResponseError,
{ response: Merge<ResponseError['response'], { body: SendGridErrorResponseBody }> }
>
type SendGridError = {
/** The error message that describes why the request failed */
message: string
/** The property that caused the error (if applicable) */
field: string | null
/** A url that links to the documentation on why the error occurred and how to fix it (if applicable) */
help: string | null
}
type SendGridErrorResponseBody = {
errors: SendGridError[]
}
// This will be removed soon (Once I implement the remaining webhook events)
export type SendGridWebhookEvent<T extends string = string> = object & {
/** The type of event that was triggered */
event: T
sg_event_id: string
/** A SendGrid ID for a sent email message
*
* @remark As far as I know, this is only absent for webhook
* account events (Since they aren't tied to a sent email). */
sg_message_id?: string
/** A Unix timestamp of when the event was triggered in SendGrid's system */
timestamp: number
}
@@ -0,0 +1,12 @@
import MarkdownIt from 'markdown-it'
const md = MarkdownIt({
xhtmlOut: true,
linkify: true,
breaks: true,
typographer: true,
}).disable('table')
export function markdownToHtml(markdown: string) {
return md.render(markdown)
}
@@ -0,0 +1,37 @@
import sgClient from '@sendgrid/client'
import sgMail, { MailDataRequired } from '@sendgrid/mail'
/** A class for making http requests to the SendGrid API
*
* @remark Always use this class over importing the client from either "@sendgrid/client" or "@sendgrid/mail".
* Otherwise, intermittent API key failures will occur. */
export class SendGridClient {
private _apiKey: string
public constructor(apiKey: string) {
this._apiKey = apiKey
}
private get _requestClient() {
sgClient.setApiKey(this._apiKey)
return sgClient
}
private get _mailClient() {
sgMail.setClient(this._requestClient)
return sgMail
}
public async getPermissionScopes() {
const [response] = await this._requestClient.request({
method: 'GET',
url: '/v3/scopes',
})
return response
}
public async sendMail(data: MailDataRequired) {
const [response] = await this._mailClient.send(data)
return response
}
}
+81
View File
@@ -0,0 +1,81 @@
import { RuntimeError } from '@botpress/sdk'
import { ResponseError } from '@sendgrid/helpers/classes'
import { SendGridResponseError } from './custom-types'
import type * as bp from '.botpress'
const UNAUTHORIZED = 401 as const
export const isSendGridError = (thrown: unknown): thrown is SendGridResponseError => {
return thrown instanceof ResponseError && typeof thrown.response.body === 'object' && 'errors' in thrown.response.body
}
export const parseError = (
ctx: bp.HandlerProps['ctx'],
thrown: unknown,
sendGridErrorMessageOverride: string | null = null
) => {
if (isSendGridError(thrown)) {
return _parseSendGridError(ctx, thrown, sendGridErrorMessageOverride)
}
if (thrown instanceof RuntimeError) {
return thrown
}
return thrown instanceof Error ? new RuntimeError(thrown.message, thrown) : new RuntimeError(String(thrown))
}
const _parseSendGridError = (
ctx: bp.HandlerProps['ctx'],
error: SendGridResponseError,
sendGridErrorMessageOverride: string | null = null
) => {
if (sendGridErrorMessageOverride) {
return new RuntimeError(sendGridErrorMessageOverride, error)
}
const errorMessage = error.response.body.errors[0]?.message ?? error.message
if (error.code === UNAUTHORIZED) {
const apiKey = ctx.configuration.apiKey.trim()
const isApiKeyEmpty = apiKey.length === 0
const errorMessage = isApiKeyEmpty
? 'No API key was sent to the SendGrid API'
: `An invalid API key was sent to the SendGrid API\n${_maskApiKey(apiKey)}`
return new RuntimeError(errorMessage, error)
}
return new RuntimeError(
`SendGrid API yielded an error with status code: "${error.code}", and message: "${errorMessage}"`,
error
)
}
/** @param apiKey Expected to be trimmed of whitespace */
const _maskApiKey = (apiKey: string) => {
let cutoffPosition = apiKey.lastIndexOf('.')
if (cutoffPosition === -1 || cutoffPosition >= apiKey.length - 1) {
cutoffPosition = Math.max(0, Math.floor(apiKey.length / 2))
}
return apiKey.slice(0, cutoffPosition)
}
export const unixTimestampToUtcDatetime = (unixTimestamp: number) => new Date(unixTimestamp * 1000).toISOString()
export type SafeJsonParseResult = { success: true; data: unknown } | { success: false; error: Error }
export const safeParseJson = (json: string): SafeJsonParseResult => {
try {
return {
success: true,
data: JSON.parse(json),
} as const
} catch (thrown: unknown) {
return {
success: false,
error: thrown instanceof Error ? thrown : new Error(String(thrown)),
} as const
}
}
@@ -0,0 +1,57 @@
import { EventWebhook } from '@sendgrid/eventwebhook'
import * as bp from '../../.botpress'
import { safeParseJson } from './utils'
const WEBHOOK_SIGNATURE_HEADER = 'x-twilio-email-event-webhook-signature' as const
const WEBHOOK_TIMESTAMP_HEADER = 'x-twilio-email-event-webhook-timestamp' as const
const ewh = new EventWebhook()
export const parseWebhookData = (props: bp.HandlerProps) => {
if (!props.req.body) {
return null
}
const result = safeParseJson(props.req.body)
if (!result.success) {
props.logger.error('Unable to parse SendGrid request body', result.error)
return null
}
const parsedBody = result.data
const publicKey = props.ctx.configuration.publicSignatureKey
const signature = props.req.headers[WEBHOOK_SIGNATURE_HEADER]
const timestamp = props.req.headers[WEBHOOK_TIMESTAMP_HEADER]
if (!publicKey) {
if (signature || timestamp) {
props.logger.warn(
"Webhook signatures have been enabled in SendGrid dashboard but the public key hasn't been provided in the Botpress configuration"
)
}
return {
body: parsedBody,
publicKey: null,
}
}
if (!signature || !timestamp) {
props.logger.error('A public key was provided but webhook request is missing the required headers')
return null
}
return {
body: parsedBody,
// The raw body MUST NOT be trimmed of whitespace!
rawBody: props.req.body,
publicKey: ewh.convertPublicKeyToECDSA(publicKey),
signature,
timestamp,
}
}
type NullableParsedWebhookData = ReturnType<typeof parseWebhookData>
type PublicKey = ReturnType<typeof ewh.convertPublicKeyToECDSA>
export const verifyWebhookSignature = (data: Extract<NullableParsedWebhookData, { publicKey: PublicKey }>) => {
return ewh.verifySignature(data.publicKey, data.rawBody, data.signature, data.timestamp)
}
@@ -0,0 +1,23 @@
import handlers from './handlers'
import { WebhookEventPayloads } from './schemas'
import * as bp from '.botpress'
export const dispatchIntegrationEvent = async (props: bp.HandlerProps, webhookEvent: WebhookEventPayloads) => {
switch (webhookEvent.event) {
case 'delivered':
return await handlers.handleDeliveredEvent(props, webhookEvent)
case 'processed':
return await handlers.handleProcessedEvent(props, webhookEvent)
case 'deferred':
return await handlers.handleDeferredEvent(props, webhookEvent)
case 'bounce':
return await handlers.handleBouncedEvent(props, webhookEvent)
case 'open':
return await handlers.handleOpenedEvent(props, webhookEvent)
case 'click':
return await handlers.handleClickedEvent(props, webhookEvent)
default:
props.logger.warn(`Ignoring unsupported webhook type: '${webhookEvent.event}'`)
return null
}
}
@@ -0,0 +1,16 @@
import * as bp from '../../../.botpress'
import { unixTimestampToUtcDatetime } from '../../misc/utils'
import { BouncedEmailWebhook } from '../schemas/emails'
export const handleBouncedEvent = async ({ client }: bp.HandlerProps, event: BouncedEmailWebhook) => {
await client.createEvent({
type: 'bounced',
payload: {
eventId: event.sg_event_id,
messageId: event.sg_message_id,
timestamp: unixTimestampToUtcDatetime(event.timestamp),
classification: event.bounce_classification,
type: event.type,
},
})
}
@@ -0,0 +1,17 @@
import * as bp from '../../../.botpress'
import { unixTimestampToUtcDatetime } from '../../misc/utils'
import { ClickedEmailWebhook } from '../schemas/emails'
export const handleClickedEvent = async ({ client }: bp.HandlerProps, event: ClickedEmailWebhook) => {
return await client.createEvent({
type: 'clicked',
payload: {
email: event.email,
eventId: event.sg_event_id,
messageId: event.sg_message_id,
timestamp: unixTimestampToUtcDatetime(event.timestamp),
url: event.url,
urlOffset: event.url_offset.index,
},
})
}
@@ -0,0 +1,15 @@
import * as bp from '../../../.botpress'
import { unixTimestampToUtcDatetime } from '../../misc/utils'
import { DeferredEmailWebhook } from '../schemas/emails'
export const handleDeferredEvent = async ({ client }: bp.HandlerProps, event: DeferredEmailWebhook) => {
return await client.createEvent({
type: 'deferred',
payload: {
attempt: event.attempt,
eventId: event.sg_event_id,
messageId: event.sg_message_id,
timestamp: unixTimestampToUtcDatetime(event.timestamp),
},
})
}
@@ -0,0 +1,15 @@
import { unixTimestampToUtcDatetime } from '../../misc/utils'
import { DeliveredEmailWebhook } from '../schemas/emails'
import * as bp from '.botpress'
export const handleDeliveredEvent = async ({ client }: bp.HandlerProps, event: DeliveredEmailWebhook) => {
return await client.createEvent({
type: 'delivered',
payload: {
eventId: event.sg_event_id,
messageId: event.sg_message_id,
timestamp: unixTimestampToUtcDatetime(event.timestamp),
email: event.email,
},
})
}
@@ -0,0 +1,15 @@
import { handleBouncedEvent } from './bounced'
import { handleClickedEvent } from './clicked'
import { handleDeferredEvent } from './deferred'
import { handleDeliveredEvent } from './delivered'
import { handleOpenedEvent } from './opened'
import { handleProcessedEvent } from './processed'
export default {
handleProcessedEvent,
handleDeliveredEvent,
handleDeferredEvent,
handleBouncedEvent,
handleOpenedEvent,
handleClickedEvent,
}
@@ -0,0 +1,15 @@
import * as bp from '../../../.botpress'
import { unixTimestampToUtcDatetime } from '../../misc/utils'
import { OpenedEmailWebhook } from '../schemas/emails'
export const handleOpenedEvent = async ({ client }: bp.HandlerProps, event: OpenedEmailWebhook) => {
return await client.createEvent({
type: 'opened',
payload: {
eventId: event.sg_event_id,
messageId: event.sg_message_id,
timestamp: unixTimestampToUtcDatetime(event.timestamp),
email: event.email,
},
})
}
@@ -0,0 +1,15 @@
import * as bp from '../../../.botpress'
import { unixTimestampToUtcDatetime } from '../../misc/utils'
import { ProcessedEmailWebhook } from '../schemas/emails'
export const handleProcessedEvent = async ({ client }: bp.HandlerProps, event: ProcessedEmailWebhook) => {
return await client.createEvent({
type: 'processed',
payload: {
eventId: event.sg_event_id,
messageId: event.sg_message_id,
timestamp: unixTimestampToUtcDatetime(event.timestamp),
sendAt: unixTimestampToUtcDatetime(event.send_at),
},
})
}
@@ -0,0 +1,67 @@
import { z } from '@botpress/sdk'
const _baseSendGridWebhookEventSchema = z.object({
sg_event_id: z.string(),
timestamp: z.number(),
})
export type BouncedEmailWebhook = z.infer<typeof bouncedEmailWebhookSchema>
export const bouncedEmailWebhookSchema = _baseSendGridWebhookEventSchema.extend({
event: z.literal('bounce').describe('The type of event that was triggered'),
/** "sg_message_id" is only absent during "Asynchronous Bounces" */
sg_message_id: z.string().optional().describe('A SendGrid ID for a sent email message'),
bounce_classification: z.string(),
/** The "Event Objects" table in the docs is out of sync because it says the "type" property isn't part of the "bounce" events even though it is. */
type: z.string(),
})
/** For event types which are associated with a sent email. They should always have the "sg_message_id" property */
const _baseSentEmailWebhookEventSchema = _baseSendGridWebhookEventSchema.extend({
sg_message_id: z.string().describe('A SendGrid ID for a sent email message'),
})
export type DeliveredEmailWebhook = z.infer<typeof deliveredEmailWebhookSchema>
export const deliveredEmailWebhookSchema = _baseSentEmailWebhookEventSchema.extend({
event: z.literal('delivered').describe('The type of event that was triggered'),
email: z.string(),
})
export type ProcessedEmailWebhook = z.infer<typeof processedEmailWebhookSchema>
export const processedEmailWebhookSchema = _baseSentEmailWebhookEventSchema.extend({
event: z.literal('processed').describe('The type of event that was triggered'),
send_at: z.number(),
})
export type DeferredEmailWebhook = z.infer<typeof deferredEmailWebhookSchema>
export const deferredEmailWebhookSchema = _baseSentEmailWebhookEventSchema.extend({
event: z.literal('deferred').describe('The type of event that was triggered'),
attempt: z.string(),
})
export type OpenedEmailWebhook = z.infer<typeof openedEmailWebhookSchema>
export const openedEmailWebhookSchema = _baseSentEmailWebhookEventSchema.extend({
event: z.literal('open').describe('The type of event that was triggered'),
email: z.string(),
})
export type ClickedEmailWebhook = z.infer<typeof clickedEmailWebhookSchema>
export const clickedEmailWebhookSchema = _baseSentEmailWebhookEventSchema.extend({
event: z.literal('click').describe('The type of event that was triggered'),
email: z.string(),
url: z.string().describe('The url of the clicked link'),
/** The "url_offset" is to better track which link was
* clicked when more than 1 link shares the same url. */
url_offset: z.object({
index: z.number().describe('A zero-based index which link was clicked in the email ordered by first appearance'),
type: z.string(),
}),
})
export const emailWebhookEventSchema = z.union([
processedEmailWebhookSchema,
deliveredEmailWebhookSchema,
deferredEmailWebhookSchema,
bouncedEmailWebhookSchema,
openedEmailWebhookSchema,
clickedEmailWebhookSchema,
])
@@ -0,0 +1,8 @@
import { z } from '@botpress/sdk'
export const ignoredWebhookSchemas = z
.object({
// Add other events as necessary
event: z.literal('dropped'),
})
.passthrough()
@@ -0,0 +1,6 @@
import { z } from '@botpress/sdk'
import { emailWebhookEventSchema } from './emails'
import { ignoredWebhookSchemas } from './ignored'
export const webhookEventPayloadSchemas = z.union([emailWebhookEventSchema, ignoredWebhookSchemas])
export type WebhookEventPayloads = z.infer<typeof webhookEventPayloadSchemas>
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config