2
0

feat(bot): Custom email body and HTML content

This commit is contained in:
Baptiste Arnaud
2022-06-12 09:05:10 +02:00
parent aeaaa5c398
commit 97980f42ca
8 changed files with 358 additions and 23 deletions

View File

@ -1,5 +1,14 @@
import { Stack, useDisclosure, Text } from '@chakra-ui/react'
import {
Stack,
useDisclosure,
Text,
Flex,
HStack,
Switch,
} from '@chakra-ui/react'
import { CodeEditor } from 'components/shared/CodeEditor'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { Input, Textarea } from 'components/shared/Textbox'
import { CredentialsType, SendEmailOptions } from 'models'
import React, { useState } from 'react'
@ -66,6 +75,18 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
replyTo,
})
const handleIsCustomBodyChange = (isCustomBody: boolean) =>
onOptionsChange({
...options,
isCustomBody,
})
const handleIsBodyCodeChange = () =>
onOptionsChange({
...options,
isBodyCode: options.isBodyCode ? !options.isBodyCode : true,
})
return (
<Stack spacing={4}>
<Stack>
@ -121,15 +142,42 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
defaultValue={options.subject ?? ''}
/>
</Stack>
<Stack>
<Text>Body: </Text>
<Textarea
data-testid="body-input"
minH="300px"
onChange={handleBodyChange}
defaultValue={options.body ?? ''}
/>
</Stack>
<SwitchWithLabel
id={'custom-body'}
label={'Custom content?'}
initialValue={options.isCustomBody ?? false}
onCheckChange={handleIsCustomBodyChange}
/>
{options.isCustomBody && (
<Stack>
<Flex justifyContent="space-between">
<Text>Content: </Text>
<HStack>
<Text fontSize="sm">Text</Text>
<Switch
size="sm"
isChecked={options.isBodyCode ?? false}
onChange={handleIsBodyCodeChange}
/>
<Text fontSize="sm">Code</Text>
</HStack>
</Flex>
{options.isBodyCode ? (
<CodeEditor
value={options.body ?? ''}
onChange={handleBodyChange}
lang="html"
/>
) : (
<Textarea
data-testid="body-input"
minH="300px"
onChange={handleBodyChange}
defaultValue={options.body ?? ''}
/>
)}
</Stack>
)}
<SmtpConfigModal
isOpen={isOpen}
onClose={onClose}

View File

@ -66,7 +66,7 @@ export const GroupNode = ({ group, groupIndex }: Props) => {
useEffect(() => {
setIsConnecting(
connectingIds?.target?.groupId === group.id &&
isNotDefined(connectingIds.target?.groupId)
isNotDefined(connectingIds.target?.blockId)
)
}, [connectingIds, group.id])

View File

@ -66,6 +66,7 @@ test.describe('Send email block', () => {
'email1@gmail.com, email2@gmail.com'
)
await page.fill('[data-testid="subject-input"]', 'Email subject')
await page.click('text="Custom body?"')
await page.fill('[data-testid="body-input"]', 'Here is my email')
await page.click('text=Preview')

View File

@ -0,0 +1,188 @@
const emailRegex =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const parseResult = (val?: string) =>
val && emailRegex.test(val) ? `<a href="mailto:${val}">${val}</a>` : val ?? ''
export const newLeadEmailContent = (
resultUrl: string,
answers: { [key: string]: string }
) => `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
</style>
</head>
<body style="word-spacing:normal;background-color:#ffffff;">
<div style="background-color:#ffffff;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="border:1px solid #E2E8F0;direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:598px;" width="598" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:598px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;padding-bottom:20px;padding-top:20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 40px;word-break:break-word;">
<div style="font-family:Helvetica Neue, Helvetica, Helvetica, Arial, sans-serif;font-size:16px;line-height:23px;text-align:left;color:#000000;">Your typebot has collected a <strong>new lead!</strong> 🥳</div>
</td>
</tr>
${Object.keys(answers)
.map(
(key) => `<tr>
<td align="left" style="font-size:0px;padding:10px 40px;word-break:break-word;">
<div style="font-family:Helvetica Neue, Helvetica, Helvetica, Arial, sans-serif;font-size:16px;line-height:23px;text-align:left;color:#000000;">${key}: ${parseResult(
answers[key]
)}</div>
</td>
</tr>`
)
.join('')}
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:598px;" width="598" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:598px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;padding-bottom:20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:0;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#0042da" role="presentation" style="border:none;border-radius:5px;cursor:auto;mso-padding-alt:10px 25px;background:#0042da;" valign="middle">
<a href="${resultUrl}" style="display:inline-block;background:#0042da;color:white;font-family:Helvetica Neue, Helvetica, Helvetica, Arial, sans-serif;font-size:16px;font-weight:500;line-height:23px;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:5px;" target="_blank"> Check results </a>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`

View File

@ -1,12 +1,30 @@
import prisma from 'libs/prisma'
import { SendEmailOptions, SmtpCredentialsData } from 'models'
import {
ResultValues,
SendEmailOptions,
SmtpCredentialsData,
Typebot,
} from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { createTransport, getTestMessageUrl } from 'nodemailer'
import { decrypt, initMiddleware, methodNotAllowed } from 'utils'
import {
decrypt,
initMiddleware,
isNotDefined,
methodNotAllowed,
omit,
parseAnswers,
} from 'utils'
import Cors from 'cors'
import { withSentry } from '@sentry/nextjs'
import { saveErrorLog, saveSuccessLog } from 'services/api/utils'
import {
getLinkedTypebots,
saveErrorLog,
saveSuccessLog,
} from 'services/api/utils'
import Mail from 'nodemailer/lib/mailer'
import { newLeadEmailContent } from 'assets/newLeadEmailContent'
const cors = initMiddleware(Cors())
@ -21,17 +39,29 @@ const defaultTransportOptions = {
}
const defaultFrom = {
name: process.env.NEXT_PUBLIC_SMTP_FROM?.split(' <')[0].replace(/"/g, ''),
email: process.env.NEXT_PUBLIC_SMTP_FROM?.match(/\<(.*)\>/)?.pop(),
name: process.env.SMTP_FROM?.split(' <')[0].replace(/"/g, ''),
email: process.env.SMTP_FROM?.match(/\<(.*)\>/)?.pop(),
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const resultId = req.query.resultId as string | undefined
const { credentialsId, recipients, body, subject, cc, bcc, replyTo } = (
const {
credentialsId,
recipients,
body,
subject,
cc,
bcc,
replyTo,
isBodyCode,
isCustomBody,
resultValues,
} = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as SendEmailOptions
) as SendEmailOptions & { resultValues: ResultValues }
const { host, port, isTlsEnabled, username, password, from } =
(await getEmailInfo(credentialsId)) ?? {}
@ -47,15 +77,35 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
pass: password,
},
}
const emailBody = await getEmailBody({
body,
isCustomBody,
isBodyCode,
typebotId,
resultValues,
})
if (!emailBody) {
await saveErrorLog(resultId, 'Email not sent', {
transportConfig,
recipients,
subject,
cc,
bcc,
replyTo,
emailBody,
})
return res.status(404).send({ message: "Couldn't find email body" })
}
const transporter = createTransport(transportConfig)
const email = {
const email: Mail.Options = {
from: `"${from.name}" <${from.email}>`,
cc,
bcc,
to: recipients,
replyTo,
subject,
text: body,
...emailBody,
}
try {
const info = await transporter.sendMail(email)
@ -97,4 +147,39 @@ const getEmailInfo = async (
return decrypt(credentials.data, credentials.iv) as SmtpCredentialsData
}
const getEmailBody = async ({
body,
isCustomBody,
isBodyCode,
typebotId,
resultValues,
}: { typebotId: string; resultValues: ResultValues } & Pick<
SendEmailOptions,
'isCustomBody' | 'isBodyCode' | 'body'
>): Promise<{ html?: string; text?: string } | undefined> => {
if (isCustomBody || isNotDefined(isCustomBody))
return {
html: isBodyCode ? body : undefined,
text: !isBodyCode ? body : undefined,
}
const typebot = (await prisma.typebot.findUnique({
where: { id: typebotId },
})) as unknown as Typebot
if (!typebot) return
const linkedTypebots = await getLinkedTypebots(typebot)
const answers = parseAnswers({
groups: [...typebot.groups, ...linkedTypebots.flatMap((t) => t.groups)],
variables: [
...typebot.variables,
...linkedTypebots.flatMap((t) => t.variables),
],
})(resultValues)
return {
html: newLeadEmailContent(
`${process.env.NEXTAUTH_URL}/typebots/${typebot.id}/results`,
omit(answers, 'submittedAt')
),
}
}
export default withSentry(handler)

View File

@ -31,7 +31,7 @@ test('should send an email', async ({ page }) => {
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('text=Send email').click()
const response = await page.waitForResponse((resp) =>
resp.request().url().includes(`/api/integrations/email`)
resp.request().url().includes(`integrations/email`)
)
const { previewUrl } = await response.json()
await page.goto(previewUrl)

View File

@ -275,7 +275,15 @@ const executeWebhook = async (
const sendEmail = async (
block: SendEmailBlock,
{ variables, apiHost, isPreview, onNewLog, resultId }: IntegrationContext
{
variables,
apiHost,
isPreview,
onNewLog,
resultId,
typebotId,
resultValues,
}: IntegrationContext
) => {
if (isPreview) {
onNewLog({
@ -288,7 +296,7 @@ const sendEmail = async (
const { options } = block
const replyTo = parseVariables(variables)(options.replyTo)
const { error } = await sendRequest({
url: `${apiHost}/api/integrations/email?resultId=${resultId}`,
url: `${apiHost}/api/typebots/${typebotId}/integrations/email?resultId=${resultId}`,
method: 'POST',
body: {
credentialsId: options.credentialsId,
@ -298,6 +306,9 @@ const sendEmail = async (
cc: (options.cc ?? []).map(parseVariables(variables)),
bcc: (options.bcc ?? []).map(parseVariables(variables)),
replyTo: replyTo !== '' ? replyTo : undefined,
isCustomBody: options.isCustomBody,
isBodyCode: options.isBodyCode,
resultValues,
},
})
onNewLog(

View File

@ -3,6 +3,8 @@ import { IntegrationBlockType, blockBaseSchema } from '../shared'
export const sendEmailOptionsSchema = z.object({
credentialsId: z.string(),
isCustomBody: z.boolean().optional(),
isBodyCode: z.boolean().optional(),
recipients: z.array(z.string()),
subject: z.string().optional(),
body: z.string().optional(),