2
0

⚗️ Implement chat API

This commit is contained in:
Baptiste Arnaud
2022-11-29 10:02:40 +01:00
parent 49ba434350
commit bf0d0c2475
122 changed files with 5075 additions and 292 deletions

View File

@ -0,0 +1,14 @@
export const defaultTransportOptions = {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : false,
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
}
export const defaultFrom = {
name: process.env.SMTP_FROM?.split(' <')[0].replace(/"/g, ''),
email: process.env.SMTP_FROM?.match(/<(.*)>/)?.pop(),
}

View File

@ -0,0 +1 @@
export { executeSendEmailBlock } from './utils/executeSendEmailBlock'

View File

@ -0,0 +1,217 @@
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
import { ExecuteIntegrationResponse } from '@/features/chat'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import { parseVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { render } from '@faire/mjml-react/dist/src/utils/render'
import { DefaultBotNotificationEmail } from 'emails'
import {
PublicTypebot,
ResultValues,
SendEmailBlock,
SendEmailOptions,
SessionState,
SmtpCredentialsData,
} from 'models'
import { createTransport } from 'nodemailer'
import Mail from 'nodemailer/lib/mailer'
import { byId, isEmpty, isNotDefined, omit, parseAnswers } from 'utils'
import { decrypt } from 'utils/api'
import { defaultFrom, defaultTransportOptions } from '../constants'
export const executeSendEmailBlock = async (
{ result, typebot }: SessionState,
block: SendEmailBlock
): Promise<ExecuteIntegrationResponse> => {
const { options } = block
const { variables } = typebot
await sendEmail({
typebotId: typebot.id,
resultId: result.id,
credentialsId: options.credentialsId,
recipients: options.recipients.map(parseVariables(variables)),
subject: parseVariables(variables)(options.subject ?? ''),
body: parseVariables(variables)(options.body ?? ''),
cc: (options.cc ?? []).map(parseVariables(variables)),
bcc: (options.bcc ?? []).map(parseVariables(variables)),
replyTo: options.replyTo
? parseVariables(variables)(options.replyTo)
: undefined,
fileUrls:
variables.find(byId(options.attachmentsVariableId))?.value ?? undefined,
isCustomBody: options.isCustomBody,
isBodyCode: options.isBodyCode,
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
const sendEmail = async ({
typebotId,
resultId,
credentialsId,
recipients,
body,
subject,
cc,
bcc,
replyTo,
isBodyCode,
isCustomBody,
fileUrls,
}: SendEmailOptions & {
typebotId: string
resultId: string
fileUrls?: string
}) => {
const { name: replyToName } = parseEmailRecipient(replyTo)
const { host, port, isTlsEnabled, username, password, from } =
(await getEmailInfo(credentialsId)) ?? {}
if (!from) return
const transportConfig = {
host,
port,
secure: isTlsEnabled ?? undefined,
auth: {
user: username,
pass: password,
},
}
const emailBody = await getEmailBody({
body,
isCustomBody,
isBodyCode,
typebotId,
resultId,
})
if (!emailBody) {
await saveErrorLog({
resultId,
message: 'Email not sent',
details: {
transportConfig,
recipients,
subject,
cc,
bcc,
replyTo,
emailBody,
},
})
}
const transporter = createTransport(transportConfig)
const fromName = isEmpty(replyToName) ? from.name : replyToName
const email: Mail.Options = {
from: fromName ? `"${fromName}" <${from.email}>` : from.email,
cc,
bcc,
to: recipients,
replyTo,
subject,
attachments: fileUrls?.split(', ').map((url) => ({ path: url })),
...emailBody,
}
try {
const info = await transporter.sendMail(email)
await saveSuccessLog({
resultId,
message: 'Email successfully sent',
details: {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
},
})
} catch (err) {
await saveErrorLog({
resultId,
message: 'Email not sent',
details: {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
error: err,
},
})
}
}
const getEmailInfo = async (
credentialsId: string
): Promise<SmtpCredentialsData | undefined> => {
if (credentialsId === 'default')
return {
host: defaultTransportOptions.host,
port: defaultTransportOptions.port,
username: defaultTransportOptions.auth.user,
password: defaultTransportOptions.auth.pass,
isTlsEnabled: undefined,
from: defaultFrom,
}
const credentials = await prisma.credentials.findUnique({
where: { id: credentialsId },
})
if (!credentials) return
return decrypt(credentials.data, credentials.iv) as SmtpCredentialsData
}
const getEmailBody = async ({
body,
isCustomBody,
isBodyCode,
typebotId,
resultId,
}: {
typebotId: string
resultId: string
} & Pick<SendEmailOptions, 'isCustomBody' | 'isBodyCode' | 'body'>): Promise<
{ html?: string; text?: string } | undefined
> => {
if (isCustomBody || (isNotDefined(isCustomBody) && !isEmpty(body)))
return {
html: isBodyCode ? body : undefined,
text: !isBodyCode ? body : undefined,
}
const typebot = (await prisma.publicTypebot.findUnique({
where: { typebotId },
})) as unknown as PublicTypebot
if (!typebot) return
const linkedTypebots = await getLinkedTypebots(typebot)
const resultValues = (await prisma.result.findUnique({
where: { id: resultId },
include: { answers: true },
})) as ResultValues | null
if (!resultValues) return
const answers = parseAnswers(typebot, linkedTypebots)(resultValues)
return {
html: render(
<DefaultBotNotificationEmail
resultsUrl={`${process.env.NEXTAUTH_URL}/typebots/${typebot.id}/results`}
answers={omit(answers, 'submittedAt')}
/>
).html,
}
}
const parseEmailRecipient = (
recipient?: string
): { email?: string; name?: string } => {
if (!recipient) return {}
if (recipient.includes('<')) {
const [name, email] = recipient.split('<')
return {
name: name.replace(/>/g, '').trim().replace(/"/g, ''),
email: email.replace('>', '').trim(),
}
}
return {
email: recipient,
}
}

View File

@ -0,0 +1,56 @@
import test, { expect } from '@playwright/test'
import { createSmtpCredentials } from '../../../../test/utils/databaseActions'
import cuid from 'cuid'
import { SmtpCredentialsData } from 'models'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { getTestAsset } from '@/test/utils/playwright'
const mockSmtpCredentials: SmtpCredentialsData = {
from: {
email: 'marley.cummings@ethereal.email',
name: 'Marley Cummings',
},
host: 'smtp.ethereal.email',
port: 587,
username: 'marley.cummings@ethereal.email',
password: 'E5W1jHbAmv5cXXcut2',
}
test.beforeAll(async () => {
try {
const credentialsId = 'send-email-credentials'
await createSmtpCredentials(credentialsId, mockSmtpCredentials)
} catch (err) {
console.error(err)
}
})
test('should send an email', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/sendEmail.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/${typebotId}-public`)
const [response] = await Promise.all([
page.waitForResponse((resp) =>
resp.request().url().includes(`integrations/email`)
),
typebotViewer(page).locator('text=Send email').click(),
])
const { previewUrl } = await response.json()
await page.goto(previewUrl)
await expect(page.locator('text="Hey!"')).toBeVisible()
await expect(
page.locator(`text="${mockSmtpCredentials.from.name}"`)
).toBeVisible()
await expect(page.locator('text="<test1@gmail.com>" >> nth=0')).toBeVisible()
await expect(page.locator('text="<test2@gmail.com>" >> nth=0')).toBeVisible()
await expect(
page.locator('text="<baptiste.arnaud95@gmail.com>" >> nth=0')
).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(page.locator('text="Email successfully sent"')).toBeVisible()
})