chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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'),
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user