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