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,24 @@
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 Resend's send email endpoint for successful requests. */
export const sendEmailOutputSchema = z.object({
emailId: NonBlankString.or(z.null()).describe('The id of a successfully sent email').title('Email ID'),
})
+18
View File
@@ -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 represents an email address.
*
* @remark "correspondent" can refer to both the sender and the receiver of an email. */
export const EmailAddressSchema = NonBlankString.email().brand('Email')
+65
View File
@@ -0,0 +1,65 @@
import { z } from '@botpress/sdk'
import { emailHeaderSchema, emailTagSchema } from '../src/webhook-events/schemas/email'
const _baseWebhookEmailEventSchema = z.object({
emailId: z.string().optional().describe('The ID for the sent email').title('Email ID'),
createdAt: z
.string()
.datetime()
.describe('An ISO8601 datetime representing when the event was triggered')
.title('Event datetime'),
subject: z.string().describe('The subject of the email').title('Email subject'),
from: z.string().describe('The sender of the email').title('Email sender'),
to: z.array(z.string()).min(1).describe('The recipients of the email').title('Email recipients'),
cc: z.array(z.string()).min(1).optional().describe('The carbon copy recipients of the email').title('Carbon Copy'),
bcc: z
.array(z.string())
.min(1)
.optional()
.describe('The blind carbon copy recipients of the email')
.title('Blind Carbon Copy'),
headers: z.array(emailHeaderSchema).min(1).optional().describe('The headers of the email').title('Email headers'),
tags: z.array(emailTagSchema).min(1).optional().describe('The tags of the email').title('Email tags'),
})
export const sentEmailEventSchema = _baseWebhookEmailEventSchema
export const delayedDeliveryEmailEventSchema = _baseWebhookEmailEventSchema
export const deliveredEmailEventSchema = _baseWebhookEmailEventSchema
export const markedAsSpamEmailEventSchema = _baseWebhookEmailEventSchema
const _emailErrorEventSchema = _baseWebhookEmailEventSchema.extend({
reason: z.string().describe('The reason this event was triggered').title('Event reason'),
})
export const bouncedEmailEventSchema = _emailErrorEventSchema.extend({
type: z
.string()
.describe('The Resend type for why the email bounced (e.g. "Permanent", "Transient", "Undetermined")')
.title('Bounce type'),
subtype: z
.string()
.describe('The Resend subtype for why the email bounced (e.g. "General", "NoEmail", "MailboxFull", etc.)')
.title('Bounce subtype'),
})
export const openedEmailEventSchema = _baseWebhookEmailEventSchema.extend({
openedAt: z
.string()
.datetime()
.describe('An ISO8601 datetime representing when the email was opened')
.title('Email opened datetime'),
})
export const clickedEmailLinkEventSchema = _baseWebhookEmailEventSchema.extend({
clickedAt: z
.string()
.datetime()
.describe('An ISO8601 datetime representing when a link in the email was clicked')
.title('Link clicked datetime'),
url: z.string().describe('The destination URL of the link that was clicked').title('Clicked URL'),
})
export const failedToSendEmailEventSchema = _emailErrorEventSchema
+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,
},
},
},
]
+53
View File
@@ -0,0 +1,53 @@
# Resend Integration
## Overview
`@botpresshub/resend` is an integration that allows a Botpress chatbot to send emails via the Resend API.
## Configuration
### Authenticating your domain (https://resend.com/domains)
1. Login to the Resend dashboard (https://resend.com/)
2. In the navigation bar on the left, click on "Domains"
3. Near the top-right, click "Add Domain"
4. In the "Name" field, enter your domain (e.g. "Botpress.com")
5. (Optional) Select the server region closest to your user base
6. (Optional) Customize the return path (Only recommended if you know what this does)
7. Click "Add Domain" (Below "Advanced options")
8. Add the DNS records in the portal of your domain provider (e.g. SquareSpace, GoDaddy, etc.)
1. While the DMARC record is optional, it's recommended to protect against spoofing/unauthorized use of the domain
9. Click "I've added the records" and wait for each status to be marked as "Verified"
10. Now you're ready to send emails with your domain. Happy Emailing!
### Acquiring an API key (https://resend.com/api-keys)
1. Login to the Resend dashboard (https://resend.com/)
2. In the navigation bar on the left, click on "API Keys"
3. Near the top-right, click "Create API Key"
4. Give the key a name & grant the key "Sending Access"
1. Optionally, choose which domains you want the key to send emails through
5. Click "Add" to generate the API Key
6. Copy the resulting API key into a secure location as it will only be shown once
### Setting up Webhooks (https://resend.com/webhooks)
1. Login to the Resend dashboard (https://resend.com/)
2. In the navigation bar on the left, click on "Webhooks"
3. Near the top-right, click "Add Webhook"
4. Copy the webhook URL from the Botpress integration config & paste it into Resend's "Endpoint URL" input field
5. Select which webhook events you want to listen for
6. Click "Add" (Below the "Select events to listen" section)
7. (Optional, but recommended) Copy the "Signing Secret" from Resend and paste it into the "Webhook Signing Secret" of the Botpress integration config
8. Now your bot is ready to listen for events from Resend
## 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://resend.com/docs/introduction
- https://resend.com/docs/dashboard/domains/introduction
- https://resend.com/docs/dashboard/api-keys/introduction
- https://resend.com/docs/dashboard/emails/send-test-emails
+3
View File
@@ -0,0 +1,3 @@
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M186 447.471V154H318.062C336.788 154 353.697 158.053 368.79 166.158C384.163 174.263 396.181 185.443 404.845 199.698C413.51 213.672 417.842 229.604 417.842 247.491C417.842 265.938 413.51 282.568 404.845 297.381C396.181 311.915 384.302 323.375 369.209 331.759C354.117 340.144 337.067 344.337 318.062 344.337H253.917V447.471H186ZM348.667 447.471L274.041 314.99L346.99 304.509L430 447.471H348.667ZM253.917 289.835H311.773C319.04 289.835 325.329 288.298 330.639 285.223C336.229 281.869 340.421 277.258 343.216 271.388C346.291 265.519 347.828 258.811 347.828 251.265C347.828 243.718 346.151 237.15 342.797 231.56C339.443 225.691 334.552 221.219 328.124 218.144C321.975 215.07 314.428 213.533 305.484 213.533H253.917V289.835Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 855 B

@@ -0,0 +1,100 @@
import { IntegrationDefinition, z } from '@botpress/sdk'
import { sendEmailOutputSchema, sendMailInputSchema } from './definitions/actions'
import {
bouncedEmailEventSchema,
clickedEmailLinkEventSchema,
delayedDeliveryEmailEventSchema,
deliveredEmailEventSchema,
failedToSendEmailEventSchema,
markedAsSpamEmailEventSchema,
openedEmailEventSchema,
sentEmailEventSchema,
} from './definitions/events'
export default new IntegrationDefinition({
name: 'resend',
title: 'Resend',
version: '0.1.10',
readme: 'hub.md',
icon: 'icon.svg',
description: 'Send markdown rich-text emails using the Resend email service.',
configuration: {
schema: z.object({
apiKey: z.string().secret().min(1).describe('Your Resend API Key').title('Resend API Key'),
signingSecret: z
.string()
.secret()
.optional()
.describe(
'The secret key used to verify the authenticity of the webhook requests (Found in the webhook details page)'
)
.title('Webhook Signing Secret'),
}),
},
actions: {
sendMail: {
title: 'Send Email',
description: 'Sends an email to the specified email address',
input: {
schema: sendMailInputSchema,
},
output: {
schema: sendEmailOutputSchema,
},
},
},
events: {
delivered: {
title: 'Email delivered',
description:
"An event that triggers when the Resend API delivers an 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 a sent email bounces. (e.g. Invalid Address, blocked, etc)',
schema: bouncedEmailEventSchema,
},
delayedDelivery: {
title: 'Email delivery delayed',
description:
"An event that triggers when the Resend API fails a delivery attempt to the recipient's email server. (This will usually re-attempt a few times before stopping)",
schema: delayedDeliveryEmailEventSchema,
},
sent: {
title: 'Email sent',
description: 'An event that triggers when the Resend API has processed and sent the email.',
schema: sentEmailEventSchema,
},
opened: {
title: 'Email opened',
description:
"An event that triggers when the 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 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,
},
markedAsSpam: {
title: 'Email marked as spam',
description: 'An event that triggers when the sent email is marked as spam by the recipient.',
schema: markedAsSpamEmailEventSchema,
},
// I was never able to trigger this event
failed: {
title: 'Email failed to send',
description: 'An event that triggers when the email could not be sent at all.',
schema: failedToSendEmailEventSchema,
},
},
__advanced: {
useLegacyZuiTransformer: true,
},
attributes: {
category: 'Marketing & Email',
repo: 'botpress',
},
})
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@botpresshub/resend",
"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:*",
"markdown-it": "^14.1.0",
"resend": "^4.6.0",
"svix": "^1.69.0"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*",
"@types/markdown-it": "^14.1.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
import { sendMail } from './send-mail'
export default {
sendMail,
}
@@ -0,0 +1,28 @@
import { Resend } from 'resend'
import { markdownToHtml } from '../misc/markdown-utils'
import { parseError } from '../misc/utils'
import * as bp from '.botpress'
export const sendMail: bp.IntegrationProps['actions']['sendMail'] = async ({ ctx, input, logger }) => {
const client = new Resend(ctx.configuration.apiKey)
const { data, error: thrown } = await client.emails.send({
from: input.from,
to: input.to,
cc: input.cc,
bcc: input.bcc,
subject: input.subject,
html: markdownToHtml(input.body),
replyTo: input.replyTo,
})
if (thrown) {
const error = parseError(thrown)
logger.forBot().error('Failed to send email', error)
throw error
}
return {
emailId: data?.id ?? null,
}
}
+50
View File
@@ -0,0 +1,50 @@
import { RuntimeError } from '@botpress/sdk'
import { ErrorResponse, Resend } from 'resend'
import actions from './actions'
import { ResendError } from './misc/ResendError'
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'
const FALLBACK_ERROR_RESP: ErrorResponse = { message: 'Unable to evaluate API key validity', name: 'application_error' }
export default new bp.Integration({
register: async ({ ctx }) => {
const client = new Resend(ctx.configuration.apiKey)
const { data, error } = await client.emails.send({
from: 'onboarding@resend.dev',
to: 'delivered@resend.dev',
subject: 'API Key Validation Test',
text: '<p>Testing API key validity</p>',
})
if (error || !data) {
const cause = new ResendError(error ?? FALLBACK_ERROR_RESP)
throw new RuntimeError('An invalid API key was provided.', cause)
}
},
unregister: async () => {},
actions,
channels: {},
handler: async (props) => {
const result = parseWebhookData(props)
if (!result.success) {
props.logger.forBot().error(result.error.message, result.error)
return
}
if (result.data.signingSecret !== null && !verifyWebhookSignature(result.data)) {
props.logger.forBot().error("The provided webhook payload failed it's signature validation")
return
}
const payloadResult = webhookEventPayloadSchemas.safeParse(result.data.body)
if (!payloadResult.success) {
props.logger.forBot().error('Unexpected webhook event payload', payloadResult.error)
return
}
await dispatchIntegrationEvent(props, payloadResult.data)
},
})
@@ -0,0 +1,8 @@
import { ErrorResponse } from 'resend'
export class ResendError extends Error {
public constructor(errorResp: ErrorResponse) {
super(errorResp.message)
this.name = errorResp.name
}
}
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
export type Result<T> = { success: true; data: T } | { success: false; error: Error }
+47
View File
@@ -0,0 +1,47 @@
import { RuntimeError } from '@botpress/client/src'
import { ErrorResponse } from 'resend'
import { ResendError } from './ResendError'
import { Result } from './types'
/** A helper function that allows me to check if an unknown value
* is a non-null object that contains the specified property.
*
* @remark This exists since `Object.prototype.hasOwnProperty` doesn't
* smart-cast the value into an object type containing the property. */
const isNonNullObjectAndHasProperty = <K extends PropertyKey>(
value: unknown,
property: K
): value is object & Record<K, unknown> => typeof value === 'object' && value?.hasOwnProperty(property) === true
export const isResendError = (thrown: unknown): thrown is ErrorResponse => {
return isNonNullObjectAndHasProperty(thrown, 'message') && 'name' in thrown && !(thrown instanceof Error)
}
export const parseError = (thrown: unknown) => {
if (isResendError(thrown)) {
return new RuntimeError(
`Resend API yielded an error of type: "${thrown.name}", and message: "${thrown.message}"`,
new ResendError(thrown)
)
}
if (thrown instanceof RuntimeError) {
return thrown
}
return thrown instanceof Error ? new RuntimeError(thrown.message, thrown) : new RuntimeError(String(thrown))
}
export const safeParseJson = (json: string): Result<unknown> => {
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,91 @@
import { Webhook } from 'svix'
import { Result } from './types'
import { safeParseJson } from './utils'
import * as bp from '.botpress'
const SVIX_ID_HEADER = 'svix-id'
const SVIX_SIGNATURE_HEADER = 'svix-signature'
const SVIX_TIMESTAMP_HEADER = 'svix-timestamp'
type ParsedWebhookData =
| { body: unknown; signingSecret: null }
| {
body: unknown
signingSecret: string
payload: string
svixId: string
svixSignature: string
svixTimestamp: string
}
export const parseWebhookData = (props: bp.HandlerProps): Result<ParsedWebhookData> => {
if (!props.req.body?.trim()) {
return { success: false, error: new Error('Received empty webhook payload') }
}
const result = safeParseJson(props.req.body)
if (!result.success) {
return { success: false, error: new Error('Unable to parse Resend Webhook Payload', result.error) }
}
const parsedBody = result.data
const signingSecret = props.ctx.configuration.signingSecret
const svixId = props.req.headers[SVIX_ID_HEADER]
const svixSignature = props.req.headers[SVIX_SIGNATURE_HEADER]
const svixTimestamp = props.req.headers[SVIX_TIMESTAMP_HEADER]
if (!signingSecret) {
if (svixId || svixSignature || svixTimestamp) {
props.logger
.forBot()
.warn(
"Webhook signatures are enabled in Resend but the signing secret hasn't been provided in the Botpress configuration"
)
}
return {
success: true,
data: {
body: parsedBody,
signingSecret: null,
},
}
}
if (!svixId || !svixSignature || !svixTimestamp) {
return {
success: false,
error: new Error('A signing secret was provided but webhook request is missing the required headers'),
}
}
return {
success: true,
data: {
body: parsedBody,
// The raw body MUST NOT be trimmed of whitespace!
payload: props.req.body,
signingSecret,
svixId,
svixTimestamp,
svixSignature,
},
}
}
export const verifyWebhookSignature = (data: Extract<ParsedWebhookData, { signingSecret: string }>) => {
const wh = new Webhook(data.signingSecret)
const headers = {
'svix-id': data.svixId,
'svix-timestamp': data.svixTimestamp,
'svix-signature': data.svixSignature,
}
try {
wh.verify(data.payload, headers)
return true
} catch {
return false
}
}
@@ -0,0 +1,27 @@
import * as emailHandlers from './handlers/email'
import { WebhookEventPayloads } from './schemas'
import * as bp from '.botpress'
export const dispatchIntegrationEvent = async (props: bp.HandlerProps, webhookEvent: WebhookEventPayloads) => {
switch (webhookEvent.type) {
case 'email.sent':
return await emailHandlers.handleSentEvent(props, webhookEvent)
case 'email.delivered':
return await emailHandlers.handleDeliveredEvent(props, webhookEvent)
case 'email.delivery_delayed':
return await emailHandlers.handleDeliveryDelayedEvent(props, webhookEvent)
case 'email.complained':
return await emailHandlers.handleMarkedAsSpamEvent(props, webhookEvent)
case 'email.bounced':
return await emailHandlers.handleBouncedEvent(props, webhookEvent)
case 'email.opened':
return await emailHandlers.handleOpenedEvent(props, webhookEvent)
case 'email.clicked':
return await emailHandlers.handleLinkClickedEvent(props, webhookEvent)
case 'email.failed':
return await emailHandlers.handleFailedToSendEvent(props, webhookEvent)
default:
props.logger.warn(`Ignoring unsupported webhook type: '${webhookEvent.type}'`)
return null
}
}
@@ -0,0 +1,192 @@
import {
BaseEmailWebhookData,
EmailBouncedWebhook,
EmailDelayedWebhook,
EmailDeliveredWebhook,
EmailFailedToSendWebhook,
EmailLinkClickedWebhook,
EmailMarkedAsSpamWebhook,
EmailOpenedWebhook,
EmailSentWebhook,
} from '../schemas/email'
import * as bp from '.botpress'
export const handleSentEvent = async ({ client }: bp.HandlerProps, event: EmailSentWebhook) => {
const { cc, bcc, rest: headers } = _extractCcsAndBccs(event.data)
return await client.createEvent({
type: 'sent',
payload: {
emailId: event.data.email_id,
createdAt: event.created_at.toISOString(),
to: event.data.to,
from: event.data.from,
subject: event.data.subject,
headers: _undefinedIfEmpty(headers),
tags: _undefinedIfEmpty(event.data.tags),
cc,
bcc,
},
})
}
export const handleDeliveredEvent = async ({ client }: bp.HandlerProps, event: EmailDeliveredWebhook) => {
const { cc, bcc, rest: headers } = _extractCcsAndBccs(event.data)
return await client.createEvent({
type: 'delivered',
payload: {
emailId: event.data.email_id,
createdAt: event.created_at.toISOString(),
to: event.data.to,
from: event.data.from,
subject: event.data.subject,
headers: _undefinedIfEmpty(headers),
tags: _undefinedIfEmpty(event.data.tags),
cc,
bcc,
},
})
}
export const handleDeliveryDelayedEvent = async ({ client }: bp.HandlerProps, event: EmailDelayedWebhook) => {
const { cc, bcc, rest: headers } = _extractCcsAndBccs(event.data)
return await client.createEvent({
type: 'delayedDelivery',
payload: {
emailId: event.data.email_id,
createdAt: event.created_at.toISOString(),
to: event.data.to,
from: event.data.from,
subject: event.data.subject,
headers: _undefinedIfEmpty(headers),
tags: _undefinedIfEmpty(event.data.tags),
cc,
bcc,
},
})
}
export const handleMarkedAsSpamEvent = async ({ client }: bp.HandlerProps, event: EmailMarkedAsSpamWebhook) => {
const { cc, bcc, rest: headers } = _extractCcsAndBccs(event.data)
return await client.createEvent({
type: 'markedAsSpam',
payload: {
emailId: event.data.email_id,
createdAt: event.created_at.toISOString(),
to: event.data.to,
from: event.data.from,
subject: event.data.subject,
headers: _undefinedIfEmpty(headers),
tags: _undefinedIfEmpty(event.data.tags),
cc,
bcc,
},
})
}
export const handleBouncedEvent = async ({ client }: bp.HandlerProps, event: EmailBouncedWebhook) => {
const { cc, bcc, rest: headers } = _extractCcsAndBccs(event.data)
return await client.createEvent({
type: 'bounced',
payload: {
emailId: event.data.email_id,
createdAt: event.created_at.toISOString(),
to: event.data.to,
from: event.data.from,
subject: event.data.subject,
headers: _undefinedIfEmpty(headers),
tags: _undefinedIfEmpty(event.data.tags),
cc,
bcc,
type: event.data.bounce.type,
subtype: event.data.bounce.subType,
reason: event.data.bounce.message,
},
})
}
export const handleOpenedEvent = async ({ client }: bp.HandlerProps, event: EmailOpenedWebhook) => {
const { cc, bcc, rest: headers } = _extractCcsAndBccs(event.data)
return await client.createEvent({
type: 'opened',
payload: {
emailId: event.data.email_id,
createdAt: event.created_at.toISOString(),
to: event.data.to,
from: event.data.from,
subject: event.data.subject,
headers: _undefinedIfEmpty(headers),
tags: _undefinedIfEmpty(event.data.tags),
cc,
bcc,
openedAt: event.data.open.timestamp.toISOString(),
},
})
}
export const handleLinkClickedEvent = async ({ client }: bp.HandlerProps, event: EmailLinkClickedWebhook) => {
const { cc, bcc, rest: headers } = _extractCcsAndBccs(event.data)
return await client.createEvent({
type: 'clicked',
payload: {
emailId: event.data.email_id,
createdAt: event.created_at.toISOString(),
to: event.data.to,
from: event.data.from,
subject: event.data.subject,
headers: _undefinedIfEmpty(headers),
tags: _undefinedIfEmpty(event.data.tags),
cc,
bcc,
clickedAt: event.data.click.timestamp.toISOString(),
url: event.data.click.link,
},
})
}
// I have yet to be able to trigger this
export const handleFailedToSendEvent = async ({ client }: bp.HandlerProps, event: EmailFailedToSendWebhook) => {
const { cc, bcc, rest: headers } = _extractCcsAndBccs(event.data)
return await client.createEvent({
type: 'failed',
payload: {
emailId: event.data.email_id,
createdAt: event.created_at.toISOString(),
to: event.data.to,
from: event.data.from,
subject: event.data.subject,
headers: _undefinedIfEmpty(headers),
tags: _undefinedIfEmpty(event.data.tags),
cc,
bcc,
reason: event.data.failed.reason,
},
})
}
// ======= Utility Functions =======
const _undefinedIfEmpty = <T>(arr: T[] | undefined): T[] | undefined => (arr && arr?.length > 0 ? arr : undefined)
const _extractCcsAndBccs = (data: BaseEmailWebhookData) => {
const extractHeaderValuesOfName = (name: string) => {
if (!data.headers) return undefined
const headerValues = data.headers.reduce((acc, header) => {
if (header.name.toLowerCase() !== name) return acc
return acc.concat(header.value)
}, [] as string[])
return _undefinedIfEmpty(headerValues)
}
return {
cc: extractHeaderValuesOfName('cc'),
bcc: extractHeaderValuesOfName('bcc'),
rest: data.headers?.filter(({ name }) => name.toLowerCase() !== 'cc' && name.toLowerCase() !== 'bcc'),
}
}
@@ -0,0 +1,131 @@
import { z } from '@botpress/sdk'
// Check if events exist for "queued", "scheduled" & "canceled" (It's not documented in the webhook events doc, but might exist in the wild)
// Suspicion comes from: https://resend.com/docs/dashboard/emails/introduction#understand-email-events
// IMPORTANT NOTE: For the following events where it may only affect one of the
// recipients, Resend doesn't distinguish which recipient the event occurred to:
// - "email.delivery_delayed"
// - "email.complained"
// - "email.bounced"
// - "email.opened"
// - "email.clicked"
// - "email.failed"
export const emailHeaderSchema = z.object({
name: z.string().title('Name').describe('The name of the header'),
value: z.string().title('Value').describe('The value of the header'),
})
export const emailTagSchema = z.object({
name: z.string().title('Name').describe('The name of the tag'),
value: z.string().title('Value').describe('The value of the tag'),
})
// Check if "cc" & "bcc" are included in the "to" field (It's included in an optional "headers" field)
export type BaseEmailWebhookData = z.infer<typeof _baseEmailWebhookDataSchema>
const _baseEmailWebhookDataSchema = z.object({
/** This "created_at" is for the sent email */
created_at: z.coerce.date(),
email_id: z.string(),
from: z.string(),
to: z.array(z.string()),
subject: z.string(),
headers: z.array(emailHeaderSchema).optional(),
tags: z.array(emailTagSchema).optional(),
})
export type EmailSentWebhook = z.infer<typeof _emailSentWebhookSchema>
const _emailSentWebhookSchema = z.object({
/** This "created_at" is for the triggered event */
type: z.literal('email.sent'),
data: _baseEmailWebhookDataSchema,
created_at: z.coerce.date(),
})
export type EmailDeliveredWebhook = z.infer<typeof _emailDeliveredWebhookSchema>
const _emailDeliveredWebhookSchema = z
.object({
type: z.literal('email.delivered'),
data: _baseEmailWebhookDataSchema,
created_at: z.coerce.date(),
})
.describe(
"A webhook that indicates that an email was delivered to the recipient's email server. (However it is still possible to bounce after this)"
)
export type EmailDelayedWebhook = z.infer<typeof _emailDelayedWebhookSchema>
const _emailDelayedWebhookSchema = z.object({
type: z.literal('email.delivery_delayed'),
data: _baseEmailWebhookDataSchema,
created_at: z.coerce.date(),
})
export type EmailMarkedAsSpamWebhook = z.infer<typeof _emailMarkedAsSpamWebhookSchema>
const _emailMarkedAsSpamWebhookSchema = z.object({
type: z.literal('email.complained'),
data: _baseEmailWebhookDataSchema,
created_at: z.coerce.date(),
})
export type EmailBouncedWebhook = z.infer<typeof _emailBouncedWebhookSchema>
const _emailBouncedWebhookSchema = z.object({
type: z.literal('email.bounced'),
data: _baseEmailWebhookDataSchema.extend({
bounce: z.object({
message: z.string(),
type: z.string(),
subType: z.string(),
}),
}),
created_at: z.coerce.date(),
})
export type EmailOpenedWebhook = z.infer<typeof _emailOpenedWebhookSchema>
const _emailOpenedWebhookSchema = z.object({
type: z.literal('email.opened'),
data: _baseEmailWebhookDataSchema.extend({
open: z.object({
ipAddress: z.string(),
timestamp: z.coerce.date(),
userAgent: z.string(),
}),
}),
created_at: z.coerce.date(),
})
export type EmailLinkClickedWebhook = z.infer<typeof _emailLinkClickedWebhookSchema>
const _emailLinkClickedWebhookSchema = z.object({
type: z.literal('email.clicked'),
data: _baseEmailWebhookDataSchema.extend({
click: z.object({
ipAddress: z.string(),
link: z.string(),
timestamp: z.coerce.date(),
userAgent: z.string(),
}),
}),
created_at: z.coerce.date(),
})
export type EmailFailedToSendWebhook = z.infer<typeof _emailFailedToSendWebhookSchema>
const _emailFailedToSendWebhookSchema = z.object({
type: z.literal('email.failed'),
data: _baseEmailWebhookDataSchema.extend({
failed: z.object({
reason: z.string(),
}),
}),
created_at: z.coerce.date(),
})
export const emailWebhookEventPayloadSchemas = z.union([
_emailSentWebhookSchema,
_emailDeliveredWebhookSchema,
_emailDelayedWebhookSchema,
_emailMarkedAsSpamWebhookSchema,
_emailBouncedWebhookSchema,
_emailOpenedWebhookSchema,
_emailLinkClickedWebhookSchema,
_emailFailedToSendWebhookSchema,
])
@@ -0,0 +1,14 @@
import { z } from '@botpress/sdk'
export const ignoredWebhookEventPayloadSchemas = z
.object({
type: z.union([
z.literal('contact.created'),
z.literal('contact.updated'),
z.literal('contact.deleted'),
z.literal('domain.created'),
z.literal('domain.updated'),
z.literal('domain.deleted'),
]),
})
.passthrough()
@@ -0,0 +1,6 @@
import { z } from '@botpress/sdk'
import { emailWebhookEventPayloadSchemas } from './email'
import { ignoredWebhookEventPayloadSchemas } from './ignored'
export const webhookEventPayloadSchemas = z.union([emailWebhookEventPayloadSchemas, ignoredWebhookEventPayloadSchemas])
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