Files
botpress--botpress/integrations/postmark/src/index.ts
T
2026-07-13 13:34:48 +08:00

429 lines
15 KiB
TypeScript

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' }
},
})