feat(bot): ✨ Custom email body and HTML content
This commit is contained in:
@ -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}
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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')
|
||||
|
188
apps/viewer/assets/newLeadEmailContent.ts
Normal file
188
apps/viewer/assets/newLeadEmailContent.ts
Normal 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>
|
||||
`
|
@ -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)
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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(),
|
||||
|
Reference in New Issue
Block a user