chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# Postmark
|
||||
|
||||
Send and receive emails through [Postmark](https://postmarkapp.com) using a mail channel.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------- |
|
||||
| **Server Token** | Your Postmark Server API Token. Found in your Postmark server's API Tokens tab. |
|
||||
| **From Email** | The default sender email address. Must be a verified sender signature in Postmark. |
|
||||
| **Webhook Secret** | Optional. Shared secret used to authenticate inbound webhooks. See _Receiving emails_ below. |
|
||||
|
||||
## Usage
|
||||
|
||||
### Receiving emails
|
||||
|
||||
Configure a Postmark inbound webhook to point to your integration's webhook URL. Incoming emails will create conversations and messages automatically.
|
||||
|
||||
Conversations are keyed by the email thread, inferred from the `References` and `In-Reply-To` headers. The first email in a thread creates a new conversation; replies that share a thread root Message-ID are added to the same conversation. Subject is kept on each message's `subject` tag.
|
||||
|
||||
If you set a **Webhook Secret** in the integration configuration, append it to your webhook URL as a query parameter: `https://your-webhook-url?secret=YOUR_SECRET`. Requests without a matching secret will be rejected.
|
||||
|
||||
### Sending emails
|
||||
|
||||
The integration sends emails as replies to inbound conversations: messages sent to a conversation created from an inbound email are delivered to the original sender with the correct `In-Reply-To` and `References` headers to preserve threading. Bot-initiated conversations (not tied to an inbound email) are not supported.
|
||||
|
||||
Use the **mail** channel to send messages. All standard message types are supported:
|
||||
|
||||
- **Text** — sent as the email body
|
||||
- **Image, Audio, Video, File** — sent as email attachments
|
||||
- **Location** — sent as a Google Maps link
|
||||
- **Card** — title, subtitle, action links, and optional image attachment
|
||||
- **Carousel** — multiple cards separated by horizontal rules
|
||||
- **Choice / Dropdown** — rendered as a numbered list
|
||||
- **Bloc** — mixed content (text, media, locations) in a single email
|
||||
|
||||
All message types support optional `cc`, `bcc`, and `subject` fields.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The integration does not track delivery events (bounces, opens, clicks).
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M30 27.4219V28.8281C29.3528 28.8281 28.8281 29.3528 28.8281 30H27.4219C27.4219 29.3528 26.8972 28.8281 26.25 28.8281C25.6028 28.8281 25.0781 29.3528 25.0781 30H23.6719C23.6719 29.3528 23.1472 28.8281 22.5 28.8281C21.8528 28.8281 21.3281 29.3528 21.3281 30H19.9219C19.9219 29.3528 19.3972 28.8281 18.75 28.8281C18.1028 28.8281 17.5781 29.3528 17.5781 30H16.1719C16.1719 29.3528 15.6472 28.8281 15 28.8281C14.3528 28.8281 13.8281 29.3528 13.8281 30H12.4219C12.4219 29.3528 11.8972 28.8281 11.25 28.8281C10.6028 28.8281 10.0781 29.3528 10.0781 30H8.67188C8.67188 29.3528 8.14721 28.8281 7.5 28.8281C6.85279 28.8281 6.32812 29.3528 6.32812 30H4.92188C4.92188 29.5813 4.69852 29.1945 4.33594 28.9851C3.97336 28.7758 3.52664 28.7758 3.16406 28.9851C2.80148 29.1945 2.57812 29.5813 2.57812 30H1.17188C1.17188 29.3528 0.647209 28.8281 0 28.8281V27.4219C0.647209 27.4219 1.17188 26.8972 1.17188 26.25C1.17188 25.6028 0.647209 25.0781 0 25.0781V23.6719C0.647209 23.6719 1.17188 23.1472 1.17188 22.5C1.17188 21.8528 0.647209 21.3281 0 21.3281V19.9219C0.647209 19.9219 1.17188 19.3972 1.17188 18.75C1.17188 18.1028 0.647209 17.5781 0 17.5781V16.1719C0.647209 16.1719 1.17188 15.6472 1.17188 15C1.17188 14.3528 0.647209 13.8281 0 13.8281V12.4219C0.647209 12.4219 1.17188 11.8972 1.17188 11.25C1.17188 10.6028 0.647209 10.0781 0 10.0781V8.67188C0.647209 8.67188 1.17188 8.14721 1.17188 7.5C1.17188 6.85279 0.647209 6.32812 0 6.32812V4.92188C0.647209 4.92187 1.17188 4.39721 1.17188 3.75C1.17188 3.10279 0.647209 2.57813 0 2.57812V1.17188C0.647209 1.17188 1.17188 0.647209 1.17188 0H2.57812C2.57813 0.647209 3.10279 1.17188 3.75 1.17188C4.39721 1.17188 4.92187 0.647209 4.92188 0H6.32812C6.32812 0.647209 6.85279 1.17188 7.5 1.17188C8.14721 1.17188 8.67188 0.647209 8.67188 0H10.0781C10.0781 0.647209 10.6028 1.17188 11.25 1.17188C11.8972 1.17188 12.4219 0.647209 12.4219 0H13.8281C13.8281 0.647209 14.3528 1.17188 15 1.17188C15.6472 1.17188 16.1719 0.647209 16.1719 0H17.5781C17.5781 0.647209 18.1028 1.17188 18.75 1.17188C19.3972 1.17188 19.9219 0.647209 19.9219 0H21.3281C21.3281 0.647209 21.8528 1.17188 22.5 1.17188C23.1472 1.17188 23.6719 0.647209 23.6719 0H25.0781C25.0781 0.647209 25.6028 1.17188 26.25 1.17188C26.8972 1.17188 27.4219 0.647209 27.4219 0H28.8281C28.8281 0.647209 29.3528 1.17188 30 1.17188V2.57812C29.3528 2.57812 28.8281 3.10279 28.8281 3.75C28.8281 4.39721 29.3528 4.92188 30 4.92188V6.32812C29.3528 6.32812 28.8281 6.85279 28.8281 7.5C28.8281 8.14721 29.3528 8.67188 30 8.67188V10.0781C29.3528 10.0781 28.8281 10.6028 28.8281 11.25C28.8281 11.8972 29.3528 12.4219 30 12.4219V13.8281C29.5813 13.8281 29.1945 14.0515 28.9851 14.4141C28.7758 14.7766 28.7758 15.2234 28.9851 15.5859C29.1945 15.9485 29.5813 16.1719 30 16.1719V17.5781C29.3528 17.5781 28.8281 18.1028 28.8281 18.75C28.8281 19.3972 29.3528 19.9219 30 19.9219V21.3281C29.3528 21.3281 28.8281 21.8528 28.8281 22.5C28.8281 23.1472 29.3528 23.6719 30 23.6719V25.0781C29.3528 25.0781 28.8281 25.6028 28.8281 26.25C28.8281 26.8972 29.3528 27.4219 30 27.4219V27.4219Z" fill="#FFDE00"/>
|
||||
<path d="M8.70312 21.3804H9.63846C10.1188 21.3804 10.4221 21.0705 10.4221 20.5798V9.62885C10.4221 9.13812 10.1188 8.82819 9.63846 8.82819H8.70312V6.40039H15.478C19.1182 6.40039 22 8.28581 22 11.8759C22 15.4917 19.1182 17.3772 15.478 17.3772H13.1523V20.5798C13.1523 21.0705 13.4556 21.3804 13.9612 21.3804H15.8571V23.8341H8.70312V21.3804ZM15.2757 14.846C17.6773 14.846 19.0676 13.8129 19.0676 11.9275C19.0676 9.99044 17.6773 9.00899 15.2757 9.00899H13.1523V14.8719H15.2757V14.846Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,223 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import proactiveConversation from 'bp_modules/proactive-conversation'
|
||||
import proactiveUser from 'bp_modules/proactive-user'
|
||||
|
||||
const emailProps = {
|
||||
cc: sdk.z
|
||||
.array(sdk.z.object({ email: sdk.z.string().email(), name: sdk.z.string().optional() }))
|
||||
.optional()
|
||||
.describe('The CC recipients of the initial email')
|
||||
.title('CC Recipients'),
|
||||
bcc: sdk.z
|
||||
.array(sdk.z.object({ email: sdk.z.string().email(), name: sdk.z.string().optional() }))
|
||||
.optional()
|
||||
.describe('The BCC recipients of the initial email')
|
||||
.title('BCC Recipients'),
|
||||
subject: sdk.z.string().optional().describe('The subject of the email').title('Email Subject'),
|
||||
}
|
||||
|
||||
export default new sdk.IntegrationDefinition({
|
||||
name: 'postmark',
|
||||
title: 'Postmark',
|
||||
description: 'Send and receive emails through Postmark',
|
||||
version: '0.1.1',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
configuration: {
|
||||
schema: sdk.z.object({
|
||||
serverToken: sdk.z.string().secret().min(1).title('Server Token').describe('Your Postmark Server API Token'),
|
||||
fromEmail: sdk.z.string().email().min(1).title('From Email').describe('Default sender email address'),
|
||||
webhookSecret: sdk.z
|
||||
.string()
|
||||
.secret()
|
||||
.optional()
|
||||
.title('Webhook Secret')
|
||||
.describe(
|
||||
'Optional secret to verify inbound webhooks. Append ?secret=YOUR_SECRET to your Postmark webhook URL.'
|
||||
),
|
||||
}),
|
||||
},
|
||||
actions: {
|
||||
sendEmail: {
|
||||
title: 'Send Email',
|
||||
description: 'Send an email through Postmark',
|
||||
input: {
|
||||
schema: sdk.z
|
||||
.object({
|
||||
conversationInformation: sdk.z
|
||||
.object({
|
||||
rootEmailId: sdk.z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The ID of the root email in the thread')
|
||||
.title('Root Email ID'),
|
||||
lastEmailId: sdk.z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The ID of the last email in the thread')
|
||||
.title('Last Email ID'),
|
||||
})
|
||||
.optional()
|
||||
.describe('Information about the conversation thread, used to group emails into threads')
|
||||
.title('Conversation Information'),
|
||||
userEmailAddress: sdk.z.string().describe('The recipient email address').title('Recipient Email Address'),
|
||||
userName: sdk.z.string().optional().describe('The recipient name').title('Recipient Name'),
|
||||
cc: sdk.z
|
||||
.array(sdk.z.object({ email: sdk.z.string().email(), name: sdk.z.string().optional() }))
|
||||
.optional()
|
||||
.describe('The CC recipients of the initial email')
|
||||
.title('CC Recipients'),
|
||||
bcc: sdk.z
|
||||
.array(sdk.z.object({ email: sdk.z.string().email(), name: sdk.z.string().optional() }))
|
||||
.optional()
|
||||
.describe('The BCC recipients of the initial email')
|
||||
.title('BCC Recipients'),
|
||||
subject: sdk.z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The subject of the initial email')
|
||||
.title('Initial Email Subject'),
|
||||
text: sdk.z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The body of the initial email that opens the thread')
|
||||
.title('Initial Email Body'),
|
||||
})
|
||||
.title('Conversation')
|
||||
.describe('The conversation object fields'),
|
||||
},
|
||||
output: {
|
||||
schema: sdk.z.object({
|
||||
conversationId: sdk.z
|
||||
.string()
|
||||
.describe('The ID of the conversation associated with the email')
|
||||
.title('Conversation ID'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
tags: {
|
||||
emailAddress: {
|
||||
title: 'Email Address',
|
||||
description: 'Email address of the user',
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
mail: {
|
||||
title: 'Mail',
|
||||
description: 'Send and receive emails through Postmark',
|
||||
conversation: {
|
||||
tags: {
|
||||
threadRootMessageId: {
|
||||
title: 'Thread Root Message ID',
|
||||
description: 'The Message-ID of the first email in the thread, used to group replies into one conversation',
|
||||
},
|
||||
postmarkEmailAddress: {
|
||||
title: 'Postmark Email Address',
|
||||
description: 'The email address used in Postmark',
|
||||
},
|
||||
userEmailAddress: {
|
||||
title: 'User Email Address',
|
||||
description: 'The email address of the user',
|
||||
},
|
||||
},
|
||||
},
|
||||
message: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'Message ID',
|
||||
description: 'The ID of the email message',
|
||||
},
|
||||
emailMessageId: {
|
||||
title: 'Email Message-ID',
|
||||
description:
|
||||
'RFC 5322 Message-ID of the email. Absent on attachment sub-messages so they are excluded from thread reconstruction.',
|
||||
},
|
||||
subject: {
|
||||
title: 'Subject',
|
||||
description: 'The subject of the email',
|
||||
},
|
||||
cc: {
|
||||
title: 'CC',
|
||||
description: 'The CC recipients of the email',
|
||||
},
|
||||
bcc: {
|
||||
title: 'BCC',
|
||||
description: 'The BCC recipients of the email',
|
||||
},
|
||||
spamScore: {
|
||||
title: 'Spam Score',
|
||||
description: 'The spam score of the email as calculated by Postmark',
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
text: { schema: sdk.messages.defaults.text.schema.extend(emailProps) },
|
||||
image: { schema: sdk.messages.defaults.image.schema.extend(emailProps) },
|
||||
audio: { schema: sdk.messages.defaults.audio.schema.extend(emailProps) },
|
||||
video: { schema: sdk.messages.defaults.video.schema.extend(emailProps) },
|
||||
file: { schema: sdk.messages.defaults.file.schema.extend(emailProps) },
|
||||
location: { schema: sdk.messages.defaults.location.schema.extend(emailProps) },
|
||||
carousel: { schema: sdk.messages.defaults.carousel.schema.extend(emailProps) },
|
||||
card: { schema: sdk.messages.defaults.card.schema.extend(emailProps) },
|
||||
dropdown: { schema: sdk.messages.defaults.dropdown.schema.extend(emailProps) },
|
||||
choice: { schema: sdk.messages.defaults.choice.schema.extend(emailProps) },
|
||||
bloc: { schema: sdk.messages.defaults.bloc.schema.extend(emailProps) },
|
||||
},
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
conversation: {
|
||||
title: 'Conversation',
|
||||
description: 'A postmark replyThread',
|
||||
schema: sdk.z.object({
|
||||
conversationInformation: sdk.z
|
||||
.object({
|
||||
rootEmailId: sdk.z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The ID of the root email in the thread')
|
||||
.title('Root Email ID'),
|
||||
lastEmailId: sdk.z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The ID of the last email in the thread')
|
||||
.title('Last Email ID'),
|
||||
})
|
||||
.describe('Information about the conversation thread, used to group emails into threads')
|
||||
.title('Conversation Information'),
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
title: 'User',
|
||||
description: 'A user in the Postmark channel',
|
||||
schema: sdk.z.object({}),
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
category: 'Communication & Channels',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
.extend(proactiveConversation, ({ entities }) => ({
|
||||
entities: { conversation: entities.conversation },
|
||||
actions: {
|
||||
getOrCreateConversation: {
|
||||
name: 'getOrCreateReplyThreadConversation',
|
||||
title: 'Get or Create Reply Thread Conversation',
|
||||
description: 'Get or create a conversation for a reply thread',
|
||||
},
|
||||
},
|
||||
}))
|
||||
.extend(proactiveUser, ({ entities }) => ({
|
||||
entities: { user: entities.user },
|
||||
actions: {
|
||||
getOrCreateUser: {
|
||||
name: 'getOrCreateUser',
|
||||
title: 'Get or Create User',
|
||||
description: 'Get or create a user in the Postmark channel',
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@botpresshub/postmark",
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"postmark": "^4.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"proactive-conversation": "../../interfaces/proactive-conversation",
|
||||
"proactive-user": "../../interfaces/proactive-user"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
const emailRecipientSchema = sdk.z.object({
|
||||
Email: sdk.z.string(),
|
||||
Name: sdk.z.string(),
|
||||
MailboxHash: sdk.z.string(),
|
||||
})
|
||||
|
||||
const headerSchema = sdk.z.object({
|
||||
Name: sdk.z.string(),
|
||||
Value: sdk.z.string(),
|
||||
})
|
||||
|
||||
const attachmentSchema = sdk.z.object({
|
||||
Name: sdk.z.string(),
|
||||
Content: sdk.z.string(),
|
||||
ContentType: sdk.z.string(),
|
||||
ContentLength: sdk.z.number(),
|
||||
ContentID: sdk.z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export type Attachment = sdk.z.infer<typeof attachmentSchema>
|
||||
|
||||
export const postmarkInboundWebhookSchema = sdk.z.object({
|
||||
From: sdk.z.string(),
|
||||
MessageStream: sdk.z.string().optional().default(''),
|
||||
FromName: sdk.z.string().optional().default(''),
|
||||
FromFull: emailRecipientSchema,
|
||||
To: sdk.z.string(),
|
||||
ToFull: sdk.z.array(emailRecipientSchema),
|
||||
Cc: sdk.z.string().optional().default(''),
|
||||
CcFull: sdk.z.array(emailRecipientSchema).optional().default([]),
|
||||
Bcc: sdk.z.string().optional().default(''),
|
||||
BccFull: sdk.z.array(emailRecipientSchema).optional().default([]),
|
||||
OriginalRecipient: sdk.z.string().optional().default(''),
|
||||
ReplyTo: sdk.z.string().optional().default(''),
|
||||
Subject: sdk.z.string(),
|
||||
MessageID: sdk.z.string(),
|
||||
Date: sdk.z.string(),
|
||||
MailboxHash: sdk.z.string().optional().default(''),
|
||||
TextBody: sdk.z.string().optional().default(''),
|
||||
HtmlBody: sdk.z.string().optional().default(''),
|
||||
StrippedTextReply: sdk.z.string().optional().default(''),
|
||||
Tag: sdk.z.string().optional().default(''),
|
||||
Headers: sdk.z.array(headerSchema).optional().default([]),
|
||||
Attachments: sdk.z.array(attachmentSchema).optional().default([]),
|
||||
})
|
||||
@@ -0,0 +1,428 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import * as pm from 'postmark'
|
||||
import { type Attachment, postmarkInboundWebhookSchema } from './inbound-webhook'
|
||||
import { messages, textToHtml } from './messages'
|
||||
import { formatRecipients, generateRfcMessageId, send } from './send-email'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const MAX_INBOUND_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB — matches outbound cap
|
||||
|
||||
const getMessageType = (contentType: string): 'image' | 'audio' | 'video' | 'file' => {
|
||||
if (contentType.startsWith('image/')) {
|
||||
return 'image'
|
||||
}
|
||||
if (contentType.startsWith('audio/')) {
|
||||
return 'audio'
|
||||
}
|
||||
if (contentType.startsWith('video/')) {
|
||||
return 'video'
|
||||
}
|
||||
return 'file'
|
||||
}
|
||||
|
||||
const sanitizeFilename = (name: string): string => name.replace(/[^\w.\-]/g, '_').slice(0, 120) || 'attachment'
|
||||
|
||||
const extractParentHeaders = (
|
||||
references: string | undefined,
|
||||
inReplyTo: string | undefined
|
||||
): { root?: string; parent?: string } => {
|
||||
if (references) {
|
||||
const first = references.trim().split(/\s+/)[0]?.replace(/[<>]/g, '')
|
||||
if (first) {
|
||||
return { root: first }
|
||||
}
|
||||
}
|
||||
if (inReplyTo) {
|
||||
const parent = inReplyTo.trim().replace(/[<>]/g, '')
|
||||
if (parent) {
|
||||
return { parent }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const extractThreadRoot = (headers: Array<{ Name: string; Value: string }>, fallbackId: string): string => {
|
||||
const { root, parent } = extractParentHeaders(
|
||||
headers.find((h) => h.Name === 'References')?.Value,
|
||||
headers.find((h) => h.Name === 'In-Reply-To')?.Value
|
||||
)
|
||||
return root ?? parent ?? fallbackId
|
||||
}
|
||||
|
||||
const extractMessageIdHeader = (headers: Array<{ Name: string; Value: string }>): string | undefined => {
|
||||
const raw = headers.find((h) => h.Name.toLowerCase() === 'message-id')?.Value
|
||||
const trimmed = raw?.trim().replace(/^<|>$/g, '')
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
type ConversationInformation = NonNullable<
|
||||
bp.actions.getOrCreateReplyThreadConversation.input.Input['conversation']['conversationInformation']
|
||||
>
|
||||
|
||||
const findConversationByThreadRoot = async (bpClient: bp.Client, threadRootMessageId: string) => {
|
||||
const { conversations } = await bpClient.listConversations({ tags: { threadRootMessageId } })
|
||||
return conversations[0]
|
||||
}
|
||||
|
||||
const findConversationByMessageId = async (bpClient: bp.Client, emailMessageId: string) => {
|
||||
const { messages } = await bpClient.listMessages({ tags: { emailMessageId } })
|
||||
const match = messages[0]
|
||||
if (!match) return undefined
|
||||
const { conversation } = await bpClient.getConversation({ id: match.conversationId })
|
||||
return conversation
|
||||
}
|
||||
|
||||
const resolveExistingConversation = async (bpClient: bp.Client, info: ConversationInformation | undefined) => {
|
||||
if (!info) return undefined
|
||||
if (info.rootEmailId) {
|
||||
return findConversationByThreadRoot(bpClient, info.rootEmailId)
|
||||
}
|
||||
if (info.lastEmailId) {
|
||||
const byRoot = await findConversationByThreadRoot(bpClient, info.lastEmailId)
|
||||
if (byRoot) return byRoot
|
||||
return findConversationByMessageId(bpClient, info.lastEmailId)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const uploadAttachment = async (attachment: Attachment, emailMessageId: string, index: number, client: bp.Client) => {
|
||||
const buffer = Buffer.from(attachment.Content, 'base64')
|
||||
if (buffer.byteLength > MAX_INBOUND_ATTACHMENT_SIZE) {
|
||||
throw new sdk.RuntimeError(
|
||||
`Inbound attachment "${attachment.Name}" exceeds maximum size of ${MAX_INBOUND_ATTACHMENT_SIZE} bytes`
|
||||
)
|
||||
}
|
||||
|
||||
const { file } = await client.upsertFile({
|
||||
key: `postmark-attachment_${emailMessageId}_${index}_${sanitizeFilename(attachment.Name)}`,
|
||||
contentType: attachment.ContentType,
|
||||
size: buffer.byteLength,
|
||||
accessPolicies: ['public_content'],
|
||||
publicContentImmediatelyAccessible: true,
|
||||
tags: {
|
||||
source: 'integration',
|
||||
integration: 'postmark',
|
||||
channel: 'mail',
|
||||
},
|
||||
})
|
||||
|
||||
const uploadResponse = await fetch(file.uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: buffer,
|
||||
headers: {
|
||||
'Content-Type': attachment.ContentType,
|
||||
'Content-Length': String(buffer.byteLength),
|
||||
'x-amz-tagging': 'public=true',
|
||||
},
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new sdk.RuntimeError(
|
||||
`Failed to upload attachment "${attachment.Name}" to storage: ${uploadResponse.status} ${uploadResponse.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
return file.url
|
||||
}
|
||||
|
||||
export default new bp.Integration({
|
||||
register: async ({ ctx, webhookUrl }) => {
|
||||
const client = new pm.ServerClient(ctx.configuration.serverToken)
|
||||
|
||||
const server = await client.getServer().catch((thrown) => {
|
||||
const statusCode = (thrown as { statusCode?: number } | null)?.statusCode
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
throw new sdk.RuntimeError('Invalid Postmark Server Token. Please check your configuration.')
|
||||
}
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
throw new sdk.RuntimeError(`Failed to verify Postmark Server Token: ${errorMessage}`)
|
||||
})
|
||||
|
||||
const InboundHookUrl = ctx.configuration.webhookSecret
|
||||
? `${webhookUrl}?secret=${encodeURIComponent(ctx.configuration.webhookSecret)}`
|
||||
: webhookUrl
|
||||
|
||||
if (server.InboundHookUrl && !server.InboundHookUrl.startsWith(webhookUrl)) {
|
||||
const displayUrl = server.InboundHookUrl.split('?')[0]
|
||||
throw new sdk.RuntimeError(
|
||||
`Postmark server already has an Inbound Hook URL set to "${displayUrl}". Clear it in the Postmark dashboard before installing this integration.`
|
||||
)
|
||||
}
|
||||
|
||||
if (server.InboundHookUrl === InboundHookUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
await client
|
||||
.editServer({
|
||||
InboundHookUrl,
|
||||
})
|
||||
.catch((thrown) => {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
throw new sdk.RuntimeError(`Failed to set Postmark Inbound Hook URL: ${errorMessage}`)
|
||||
})
|
||||
},
|
||||
unregister: async ({ ctx, webhookUrl }) => {
|
||||
const finalUrl = ctx.configuration.webhookSecret
|
||||
? `${webhookUrl}?secret=${encodeURIComponent(ctx.configuration.webhookSecret)}`
|
||||
: webhookUrl
|
||||
const client = new pm.ServerClient(ctx.configuration.serverToken)
|
||||
try {
|
||||
const { InboundHookUrl } = await client.getServer()
|
||||
if (InboundHookUrl === finalUrl) {
|
||||
await client.editServer({ InboundHookUrl: '' })
|
||||
}
|
||||
} catch (thrown) {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
throw new sdk.RuntimeError(`Failed to clear Postmark Inbound Hook URL: ${errorMessage}`)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
sendEmail: async (props) => {
|
||||
const { client: bpClient, ctx, logger, input } = props
|
||||
const { userEmailAddress, userName, cc, bcc, subject, text, conversationInformation } = input
|
||||
|
||||
const subjectLine = subject || '(No subject)'
|
||||
const pmClient = new pm.ServerClient(ctx.configuration.serverToken)
|
||||
|
||||
await bpClient.getOrCreateUser({
|
||||
name: userName || userEmailAddress,
|
||||
tags: { emailAddress: userEmailAddress },
|
||||
discriminateByTags: ['emailAddress'],
|
||||
})
|
||||
|
||||
const { id: conversationId, messageId } = await (async () => {
|
||||
const existing = await resolveExistingConversation(bpClient, conversationInformation)
|
||||
|
||||
if (existing) {
|
||||
logger.forBotOnly().debug(`Resolved existing reply thread conversation "${existing.id}"`)
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation: {
|
||||
id: existing.id,
|
||||
tags: {
|
||||
postmarkEmailAddress: existing.tags.postmarkEmailAddress,
|
||||
userEmailAddress: existing.tags.userEmailAddress,
|
||||
},
|
||||
},
|
||||
client: bpClient,
|
||||
input: { Subject: subjectLine, TextBody: text, HtmlBody: textToHtml(text), Cc: cc, Bcc: bcc },
|
||||
})
|
||||
return { id: existing.id, messageId: id }
|
||||
}
|
||||
|
||||
if (conversationInformation?.rootEmailId || conversationInformation?.lastEmailId) {
|
||||
throw new sdk.RuntimeError(
|
||||
`No Botpress conversation found for thread root "${conversationInformation.rootEmailId ?? conversationInformation.lastEmailId}"`
|
||||
)
|
||||
}
|
||||
|
||||
logger.forBotOnly().debug(`Opening a new reply thread with user email "${userEmailAddress}"`)
|
||||
const threadRootMessageId = generateRfcMessageId(ctx.configuration.fromEmail)
|
||||
await pmClient
|
||||
.sendEmail({
|
||||
From: ctx.configuration.fromEmail,
|
||||
To: userEmailAddress,
|
||||
Cc: formatRecipients(cc),
|
||||
Bcc: formatRecipients(bcc),
|
||||
Subject: subjectLine,
|
||||
TextBody: text,
|
||||
HtmlBody: textToHtml(text),
|
||||
Headers: [{ Name: 'Message-ID', Value: `<${threadRootMessageId}>` }],
|
||||
})
|
||||
.catch((thrown) => {
|
||||
if (thrown instanceof sdk.RuntimeError) throw thrown
|
||||
throw new sdk.RuntimeError(
|
||||
`Failed to send email via Postmark: ${thrown instanceof Error ? thrown.message : String(thrown)}`
|
||||
)
|
||||
})
|
||||
|
||||
const { conversation } = await bpClient.getOrCreateConversation({
|
||||
channel: 'mail',
|
||||
tags: {
|
||||
threadRootMessageId,
|
||||
postmarkEmailAddress: ctx.configuration.fromEmail,
|
||||
userEmailAddress,
|
||||
},
|
||||
discriminateByTags: ['threadRootMessageId'],
|
||||
})
|
||||
return { id: conversation.id, messageId: threadRootMessageId }
|
||||
})()
|
||||
|
||||
await bpClient.getOrCreateMessage({
|
||||
type: 'text',
|
||||
conversationId,
|
||||
userId: ctx.botId,
|
||||
origin: 'synthetic',
|
||||
tags: {
|
||||
id: messageId,
|
||||
emailMessageId: messageId,
|
||||
subject: subjectLine,
|
||||
cc: formatRecipients(cc) ?? '',
|
||||
bcc: formatRecipients(bcc) ?? '',
|
||||
},
|
||||
payload: { text, subject: subjectLine },
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
logger.forBotOnly().debug(`Sent email message "${messageId}" in conversation "${conversationId}"`)
|
||||
return { conversationId }
|
||||
},
|
||||
getOrCreateReplyThreadConversation: async (props) => {
|
||||
const { client: bpClient, logger, input } = props
|
||||
const { conversationInformation } = input.conversation
|
||||
|
||||
const conversation = await resolveExistingConversation(bpClient, conversationInformation)
|
||||
if (!conversation) {
|
||||
throw new sdk.RuntimeError('No existing conversation found for the provided conversation information')
|
||||
}
|
||||
|
||||
logger.forBotOnly().debug(`Resolved existing reply thread conversation "${conversation.id}"`)
|
||||
return { conversationId: conversation.id }
|
||||
},
|
||||
getOrCreateUser: async (props) => {
|
||||
const { client: bpClient, logger, input } = props
|
||||
const { name, email } = input
|
||||
if (!email || email.trim() === '') {
|
||||
throw new sdk.RuntimeError('Email address is required to get or create a user')
|
||||
}
|
||||
|
||||
const { user } = await bpClient.getOrCreateUser({
|
||||
name: name || email,
|
||||
tags: { emailAddress: email },
|
||||
discriminateByTags: ['emailAddress'],
|
||||
})
|
||||
|
||||
logger.forBotOnly().debug(`Resolved user with email "${email}" to user ID "${user.id}"`)
|
||||
return { userId: user.id }
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
mail: { messages },
|
||||
},
|
||||
handler: async (props) => {
|
||||
const { client, ctx, logger, req } = props
|
||||
|
||||
if (ctx.configuration.webhookSecret) {
|
||||
const queryParams = new URLSearchParams(req.query)
|
||||
const provided = Buffer.from(queryParams.get('secret') || '', 'utf8')
|
||||
const expected = Buffer.from(ctx.configuration.webhookSecret, 'utf8')
|
||||
if (provided.byteLength !== expected.byteLength || !timingSafeEqual(provided, expected)) {
|
||||
return { status: 401, body: 'Unauthorized' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.body) {
|
||||
return { status: 400, body: 'No body found in the request' }
|
||||
}
|
||||
|
||||
let parsedBody: unknown
|
||||
try {
|
||||
parsedBody = JSON.parse(req.body)
|
||||
} catch {
|
||||
logger.forBot().error('Failed to parse request body as JSON')
|
||||
return { status: 400, body: 'Invalid JSON payload' }
|
||||
}
|
||||
|
||||
const parseResult = postmarkInboundWebhookSchema.safeParse(parsedBody)
|
||||
if (!parseResult.success) {
|
||||
logger
|
||||
.forBot()
|
||||
.error(
|
||||
`Failed to parse Postmark webhook payload: ${parseResult.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`
|
||||
)
|
||||
return { status: 400, body: 'Invalid payload' }
|
||||
}
|
||||
|
||||
const email = parseResult.data
|
||||
|
||||
const { user } = await client.getOrCreateUser({
|
||||
name: email.FromName || email.From,
|
||||
tags: {
|
||||
emailAddress: email.From,
|
||||
},
|
||||
discriminateByTags: ['emailAddress'],
|
||||
})
|
||||
|
||||
for (const recipient of [...email.ToFull, ...email.CcFull, ...email.BccFull]) {
|
||||
await client.getOrCreateUser({
|
||||
name: recipient.Name || recipient.Email,
|
||||
tags: {
|
||||
emailAddress: recipient.Email,
|
||||
},
|
||||
discriminateByTags: ['emailAddress'],
|
||||
})
|
||||
}
|
||||
|
||||
const rfcMessageId = extractMessageIdHeader(email.Headers) ?? email.MessageID
|
||||
const threadRootMessageId = extractThreadRoot(email.Headers, rfcMessageId)
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'mail',
|
||||
tags: {
|
||||
threadRootMessageId,
|
||||
postmarkEmailAddress: email.To,
|
||||
userEmailAddress: email.From,
|
||||
},
|
||||
discriminateByTags: ['threadRootMessageId'],
|
||||
})
|
||||
|
||||
await client.getOrCreateMessage({
|
||||
type: 'text',
|
||||
conversationId: conversation.id,
|
||||
tags: {
|
||||
id: rfcMessageId,
|
||||
emailMessageId: rfcMessageId,
|
||||
bcc: email.Bcc,
|
||||
cc: email.Cc,
|
||||
subject: email.Subject,
|
||||
spamScore: email.Headers.find((h) => h.Name.toLowerCase() === 'x-spam-score')?.Value || '',
|
||||
},
|
||||
userId: user.id,
|
||||
payload: {
|
||||
text: email.TextBody.length > 0 ? email.TextBody : email.HtmlBody,
|
||||
subject: email.Subject,
|
||||
},
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
email.Attachments.map(async (attachment, index) => {
|
||||
try {
|
||||
const fileUrl = await uploadAttachment(attachment, email.MessageID, index, client)
|
||||
const messageType = getMessageType(attachment.ContentType)
|
||||
|
||||
const payload =
|
||||
messageType === 'image'
|
||||
? { imageUrl: fileUrl }
|
||||
: messageType === 'audio'
|
||||
? { audioUrl: fileUrl }
|
||||
: messageType === 'video'
|
||||
? { videoUrl: fileUrl }
|
||||
: { fileUrl, filename: attachment.Name }
|
||||
|
||||
await client.getOrCreateMessage({
|
||||
type: messageType,
|
||||
conversationId: conversation.id,
|
||||
tags: {
|
||||
id: `${rfcMessageId}_attachment_${index}`,
|
||||
subject: email.Subject,
|
||||
cc: email.Cc,
|
||||
bcc: email.Bcc,
|
||||
spamScore: email.Headers.find((h) => h.Name.toLowerCase() === 'x-spam-score')?.Value || '',
|
||||
},
|
||||
userId: user.id,
|
||||
payload,
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.forBot().warn(`Failed to process inbound attachment "${attachment.Name}": ${message}`)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return { status: 200, body: 'OK' }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,459 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { send, type SendEmailInput } from './send-email'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type MessageHandlers = bp.IntegrationProps['channels']['mail']['messages']
|
||||
|
||||
export const escapeHtml = (str: string): string =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
export const textToHtml = (str: string): string => escapeHtml(str).replace(/\r\n|\r|\n/g, '<br>')
|
||||
|
||||
export const sanitizeHttpUrl = (url: string): string | undefined => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return url
|
||||
}
|
||||
} catch {
|
||||
// invalid URL
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/wav': 'wav',
|
||||
'audio/webm': 'weba',
|
||||
'video/mp4': 'mp4',
|
||||
'video/webm': 'webm',
|
||||
'video/quicktime': 'mov',
|
||||
'application/pdf': 'pdf',
|
||||
'application/zip': 'zip',
|
||||
'application/json': 'json',
|
||||
'text/plain': 'txt',
|
||||
'text/html': 'html',
|
||||
'text/csv': 'csv',
|
||||
}
|
||||
|
||||
const filenameFromUrl = (url: string, contentType: string): string => {
|
||||
let pathname: string
|
||||
try {
|
||||
pathname = new URL(url).pathname
|
||||
} catch (thrown) {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
throw new sdk.RuntimeError(`Invalid URL "${url}": ${errorMessage}`)
|
||||
}
|
||||
const lastSegment = pathname.split('/').pop() || ''
|
||||
if (lastSegment.includes('.')) {
|
||||
return lastSegment
|
||||
}
|
||||
const base = lastSegment || 'attachment'
|
||||
const ext = CONTENT_TYPE_EXTENSIONS[contentType.split(';')[0]?.trim().toLowerCase() ?? '']
|
||||
return ext ? `${base}.${ext}` : base
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB — Postmark's limit
|
||||
|
||||
const fetchAsAttachment = async (
|
||||
url: string
|
||||
): Promise<{ Name: string; Content: string; ContentType: string; ContentID: string }> => {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
||||
if (!response.ok) {
|
||||
throw new sdk.RuntimeError(`Failed to fetch attachment from ${url}: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length')
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_ATTACHMENT_SIZE) {
|
||||
throw new sdk.RuntimeError(`Attachment at ${url} exceeds maximum size of 10MB`)
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
if (buffer.byteLength > MAX_ATTACHMENT_SIZE) {
|
||||
throw new sdk.RuntimeError(`Attachment at ${url} exceeds maximum size of 10MB`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
||||
return {
|
||||
Name: filenameFromUrl(url, contentType),
|
||||
Content: buffer.toString('base64'),
|
||||
ContentType: contentType,
|
||||
ContentID: '',
|
||||
}
|
||||
}
|
||||
|
||||
const tryFetchAttachment = async (url: string, logger: bp.Logger) => {
|
||||
try {
|
||||
return await fetchAsAttachment(url)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.forBot().warn(`Failed to fetch attachment, sending email without it: ${message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type AckFn = (args: { tags: { id?: string; emailMessageId?: string } }) => Promise<void>
|
||||
|
||||
const ackSent = (ack: AckFn, id: string) => ack({ tags: { id, emailMessageId: id } })
|
||||
|
||||
export const messages: MessageHandlers = {
|
||||
text: async ({ ctx, payload, conversation, client, ack }) => {
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || '(No subject)',
|
||||
HtmlBody: textToHtml(payload.text),
|
||||
TextBody: payload.text,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
image: async ({ ctx, payload, conversation, client, ack }) => {
|
||||
const attachment = await fetchAsAttachment(payload.imageUrl)
|
||||
const body = `(attachment: ${attachment.Name})`
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || '(No subject)',
|
||||
HtmlBody: `<p>${escapeHtml(body)}</p>`,
|
||||
TextBody: body,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
Attachments: [attachment],
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
file: async ({ ctx, payload, conversation, client, ack }) => {
|
||||
const attachment = await fetchAsAttachment(payload.fileUrl)
|
||||
const body = `(attachment: ${attachment.Name})`
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || '(No subject)',
|
||||
HtmlBody: `<p>${escapeHtml(body)}</p>`,
|
||||
TextBody: body,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
Attachments: [attachment],
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
audio: async ({ ctx, payload, conversation, client, ack }) => {
|
||||
const attachment = await fetchAsAttachment(payload.audioUrl)
|
||||
const body = `(attachment: ${attachment.Name})`
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || '(No subject)',
|
||||
HtmlBody: `<p>${escapeHtml(body)}</p>`,
|
||||
TextBody: body,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
Attachments: [attachment],
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
video: async ({ ctx, payload, conversation, client, ack }) => {
|
||||
const attachment = await fetchAsAttachment(payload.videoUrl)
|
||||
const body = `(attachment: ${attachment.Name})`
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || '(No subject)',
|
||||
HtmlBody: `<p>${escapeHtml(body)}</p>`,
|
||||
TextBody: body,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
Attachments: [attachment],
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
location: async ({ ctx, payload, conversation, client, ack }) => {
|
||||
const mapsUrl = `https://www.google.com/maps?q=${payload.latitude},${payload.longitude}`
|
||||
|
||||
let htmlBody = ''
|
||||
let textBody = ''
|
||||
|
||||
if (payload.title) {
|
||||
htmlBody += `<p><strong>${escapeHtml(payload.title)}</strong></p>`
|
||||
textBody += `${payload.title}\n`
|
||||
}
|
||||
if (payload.address) {
|
||||
htmlBody += `<p>${escapeHtml(payload.address)}</p>`
|
||||
textBody += `${payload.address}\n`
|
||||
}
|
||||
htmlBody += `<p><a href="${mapsUrl}">View on Google Maps</a></p>`
|
||||
textBody += mapsUrl
|
||||
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || payload.title || 'Location',
|
||||
HtmlBody: htmlBody,
|
||||
TextBody: textBody,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
card: async ({ ctx, payload, conversation, client, logger, ack }) => {
|
||||
let htmlBody = `<h2>${escapeHtml(payload.title)}</h2>`
|
||||
let textBody = payload.title
|
||||
|
||||
if (payload.subtitle) {
|
||||
htmlBody += `<p>${escapeHtml(payload.subtitle)}</p>`
|
||||
textBody += `\n${payload.subtitle}`
|
||||
}
|
||||
|
||||
if (payload.actions.length > 0) {
|
||||
htmlBody += '<p>'
|
||||
textBody += '\n'
|
||||
for (const action of payload.actions) {
|
||||
const safeUrl = action.action === 'url' ? sanitizeHttpUrl(action.value) : undefined
|
||||
if (safeUrl) {
|
||||
htmlBody += `<a href="${escapeHtml(safeUrl)}">${escapeHtml(action.label)}</a><br>`
|
||||
textBody += `\n- ${action.label}: ${safeUrl}`
|
||||
} else {
|
||||
htmlBody += `${escapeHtml(action.label)}<br>`
|
||||
textBody += `\n- ${action.label}`
|
||||
}
|
||||
}
|
||||
htmlBody += '</p>'
|
||||
}
|
||||
|
||||
const attachments: NonNullable<SendEmailInput['Attachments']> = []
|
||||
|
||||
if (payload.imageUrl) {
|
||||
const attachment = await tryFetchAttachment(payload.imageUrl, logger)
|
||||
if (attachment) {
|
||||
attachments.push(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || payload.title || '(No subject)',
|
||||
HtmlBody: htmlBody,
|
||||
TextBody: textBody,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
Attachments: attachments,
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
carousel: async ({ ctx, payload, conversation, client, logger, ack }) => {
|
||||
const htmlParts: string[] = []
|
||||
const textParts: string[] = []
|
||||
|
||||
for (const item of payload.items) {
|
||||
let itemHtml = `<h2>${escapeHtml(item.title)}</h2>`
|
||||
let itemText = item.title
|
||||
|
||||
if (item.subtitle) {
|
||||
itemHtml += `<p>${escapeHtml(item.subtitle)}</p>`
|
||||
itemText += `\n${item.subtitle}`
|
||||
}
|
||||
|
||||
if (item.actions.length > 0) {
|
||||
itemHtml += '<p>'
|
||||
itemText += '\n'
|
||||
for (const action of item.actions) {
|
||||
const safeUrl = action.action === 'url' ? sanitizeHttpUrl(action.value) : undefined
|
||||
if (safeUrl) {
|
||||
itemHtml += `<a href="${escapeHtml(safeUrl)}">${escapeHtml(action.label)}</a><br>`
|
||||
itemText += `\n- ${action.label}: ${safeUrl}`
|
||||
} else {
|
||||
itemHtml += `${escapeHtml(action.label)}<br>`
|
||||
itemText += `\n- ${action.label}`
|
||||
}
|
||||
}
|
||||
itemHtml += '</p>'
|
||||
}
|
||||
|
||||
htmlParts.push(itemHtml)
|
||||
textParts.push(itemText)
|
||||
}
|
||||
|
||||
const imageItems = payload.items.filter((item) => item.imageUrl)
|
||||
const results = await Promise.allSettled(imageItems.map((item) => fetchAsAttachment(item.imageUrl!)))
|
||||
const attachments: NonNullable<SendEmailInput['Attachments']> = []
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
attachments.push(result.value)
|
||||
} else {
|
||||
logger.forBot().warn(`Failed to fetch carousel image attachment, sending email without it: ${result.reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || '(No subject)',
|
||||
HtmlBody: htmlParts.join('<hr>'),
|
||||
TextBody: textParts.join('\n---\n'),
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
Attachments: attachments,
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
choice: async ({ ctx, payload, conversation, client, ack }) => {
|
||||
const optionsHtml = payload.options.map((opt) => `<li>${escapeHtml(opt.label)}</li>`).join('')
|
||||
const optionsText = payload.options.map((opt, i) => `${i + 1}. ${opt.label}`).join('\n')
|
||||
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || '(No subject)',
|
||||
HtmlBody: `<p>${escapeHtml(payload.text)}</p><ol>${optionsHtml}</ol>`,
|
||||
TextBody: `${payload.text}\n\n${optionsText}`,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
dropdown: async ({ ctx, payload, conversation, client, ack }) => {
|
||||
const optionsHtml = payload.options.map((opt) => `<li>${escapeHtml(opt.label)}</li>`).join('')
|
||||
const optionsText = payload.options.map((opt, i) => `${i + 1}. ${opt.label}`).join('\n')
|
||||
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || '(No subject)',
|
||||
HtmlBody: `<p>${escapeHtml(payload.text)}</p><ol>${optionsHtml}</ol>`,
|
||||
TextBody: `${payload.text}\n\n${optionsText}`,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
|
||||
bloc: async ({ ctx, payload, conversation, client, logger, ack }) => {
|
||||
const htmlParts: string[] = []
|
||||
const textParts: string[] = []
|
||||
const attachments: NonNullable<SendEmailInput['Attachments']> = []
|
||||
|
||||
for (const item of payload.items) {
|
||||
switch (item.type) {
|
||||
case 'text':
|
||||
htmlParts.push(`<p>${escapeHtml(item.payload.text)}</p>`)
|
||||
textParts.push(item.payload.text)
|
||||
break
|
||||
case 'image': {
|
||||
const a = await tryFetchAttachment(item.payload.imageUrl, logger)
|
||||
if (a) {
|
||||
attachments.push(a)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'audio': {
|
||||
const a = await tryFetchAttachment(item.payload.audioUrl, logger)
|
||||
if (a) {
|
||||
attachments.push(a)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'video': {
|
||||
const a = await tryFetchAttachment(item.payload.videoUrl, logger)
|
||||
if (a) {
|
||||
attachments.push(a)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'file': {
|
||||
const a = await tryFetchAttachment(item.payload.fileUrl, logger)
|
||||
if (a) {
|
||||
attachments.push(a)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'location': {
|
||||
const mapsUrl = `https://www.google.com/maps?q=${item.payload.latitude},${item.payload.longitude}`
|
||||
let html = ''
|
||||
let text = ''
|
||||
if (item.payload.title) {
|
||||
html += `<p><strong>${escapeHtml(item.payload.title)}</strong></p>`
|
||||
text += `${item.payload.title}\n`
|
||||
}
|
||||
if (item.payload.address) {
|
||||
html += `<p>${escapeHtml(item.payload.address)}</p>`
|
||||
text += `${item.payload.address}\n`
|
||||
}
|
||||
html += `<p><a href="${mapsUrl}">View on Google Maps</a></p>`
|
||||
text += mapsUrl
|
||||
htmlParts.push(html)
|
||||
textParts.push(text)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentNote = attachments.length > 0 ? `(attachments: ${attachments.map((a) => a.Name).join(', ')})` : ''
|
||||
const textBody = textParts.join('\n\n') || attachmentNote
|
||||
const htmlBody = htmlParts.join('') || (attachmentNote ? `<p>${escapeHtml(attachmentNote)}</p>` : '')
|
||||
|
||||
const id = await send({
|
||||
configuration: ctx.configuration,
|
||||
conversation,
|
||||
client,
|
||||
input: {
|
||||
Subject: payload.subject || '(No subject)',
|
||||
HtmlBody: htmlBody,
|
||||
TextBody: textBody,
|
||||
Cc: payload.cc,
|
||||
Bcc: payload.bcc,
|
||||
Attachments: attachments,
|
||||
},
|
||||
})
|
||||
await ackSent(ack, id)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import * as pm from 'postmark'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type SendEmailInput = Parameters<pm.ServerClient['sendEmail']>[0]
|
||||
|
||||
export type EmailRecipient = { email: string; name?: string }
|
||||
|
||||
export type SendArgs = {
|
||||
configuration: bp.configuration.Configuration
|
||||
conversation: { id: string; tags: { postmarkEmailAddress?: string; userEmailAddress?: string } }
|
||||
client: bp.Client
|
||||
input: Omit<SendEmailInput, 'From' | 'To' | 'Cc' | 'Bcc'> & {
|
||||
Cc?: EmailRecipient[]
|
||||
Bcc?: EmailRecipient[]
|
||||
}
|
||||
}
|
||||
|
||||
export const formatRecipients = (recipients?: EmailRecipient[]): string | undefined => {
|
||||
if (!recipients || recipients.length === 0) return undefined
|
||||
return recipients.map(({ email, name }) => (name ? `"${name.replace(/"/g, '\\"')}" <${email}>` : email)).join(', ')
|
||||
}
|
||||
|
||||
export const generateRfcMessageId = (fromEmail: string): string => {
|
||||
const domain = fromEmail.split('@')[1] || 'botpress.local'
|
||||
return `${randomUUID()}@${domain}`
|
||||
}
|
||||
|
||||
const sendEmailMessage = async (input: SendEmailInput, { serverToken }: { serverToken: string }): Promise<void> => {
|
||||
const client = new pm.ServerClient(serverToken)
|
||||
await client.sendEmail(input)
|
||||
}
|
||||
|
||||
const collectEmailThread = async (
|
||||
client: bp.Client,
|
||||
conversationId: string
|
||||
): Promise<{ ids: string[]; inReplyTo: string | undefined }> => {
|
||||
const emails: Array<{ id: string; createdAt: string }> = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await client.listMessages({ conversationId, nextToken })
|
||||
for (const message of res.messages) {
|
||||
const emailMessageId = message.tags.emailMessageId
|
||||
if (!emailMessageId) continue
|
||||
emails.push({ id: emailMessageId, createdAt: message.createdAt })
|
||||
}
|
||||
nextToken = res.meta.nextToken
|
||||
} while (nextToken)
|
||||
|
||||
emails.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
const ids = emails.map((e) => e.id)
|
||||
return { ids, inReplyTo: ids.at(-1) }
|
||||
}
|
||||
|
||||
export const send = async ({ configuration, conversation, client, input }: SendArgs): Promise<string> => {
|
||||
if (!conversation.tags.postmarkEmailAddress) {
|
||||
throw new sdk.RuntimeError(
|
||||
'Cannot send email: conversation is missing the "postmarkEmailAddress" tag. Ensure the conversation was created from an inbound email.'
|
||||
)
|
||||
}
|
||||
if (!conversation.tags.userEmailAddress) {
|
||||
throw new sdk.RuntimeError(
|
||||
'Cannot send email: conversation is missing the "userEmailAddress" tag. Ensure the conversation was created from an inbound email.'
|
||||
)
|
||||
}
|
||||
|
||||
const { ids, inReplyTo } = await collectEmailThread(client, conversation.id)
|
||||
const outgoingMessageId = generateRfcMessageId(configuration.fromEmail)
|
||||
const headers: Array<{ Name: string; Value: string }> = [{ Name: 'Message-ID', Value: `<${outgoingMessageId}>` }]
|
||||
if (inReplyTo) {
|
||||
headers.push({ Name: 'In-Reply-To', Value: `<${inReplyTo}>` })
|
||||
headers.push({ Name: 'References', Value: ids.map((id) => `<${id}>`).join(' ') })
|
||||
}
|
||||
|
||||
const { Cc, Bcc, ...rest } = input
|
||||
try {
|
||||
await sendEmailMessage(
|
||||
{
|
||||
...rest,
|
||||
From: configuration.fromEmail,
|
||||
To: conversation.tags.userEmailAddress,
|
||||
Cc: formatRecipients(Cc),
|
||||
Bcc: formatRecipients(Bcc),
|
||||
Headers: headers,
|
||||
},
|
||||
{ serverToken: configuration.serverToken }
|
||||
)
|
||||
return outgoingMessageId
|
||||
} catch (error) {
|
||||
if (error instanceof sdk.RuntimeError) {
|
||||
throw error
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new sdk.RuntimeError(`Failed to send email via Postmark: ${message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user