2
0

feat(editor): Add send email integration

This commit is contained in:
Baptiste Arnaud
2022-02-07 18:06:37 +01:00
parent f4336b83cc
commit d6238b3474
48 changed files with 2119 additions and 2606 deletions

View File

@ -1,14 +1,24 @@
DATABASE_URL=postgresql://username:password@host:5450/typebot?schema=public DATABASE_URL=postgresql://username:password@host:5450/typebot?schema=public
SECRET=secret SECRET=q3t6v9y$B&E)H@McQfTjWnZr4u7x!z%C # 256-bits secret (can be generated here: https://www.allkeysgenerator.com/Random/Security-Encryption-Key-Generator.aspx)
NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_URL=http://localhost:3000
# Used for email auth and email notifications # Used for email auth and email notifications
EMAIL_SERVER_USER=username AUTH_EMAIL_SERVER_USERNAME=username
EMAIL_SERVER_PASSWORD=password AUTH_EMAIL_SERVER_PASSWORD=password
EMAIL_SERVER_HOST=smtp.example.com AUTH_EMAIL_SERVER_HOST=smtp.example.com
EMAIL_SERVER_PORT=587 AUTH_EMAIL_SERVER_PORT=587
EMAIL_FROM=noreply@example.com AUTH_EMAIL_FROM_EMAIL=noreply@example.com
AUTH_EMAIL_FROM_NAME="John Smith"
# (Optional) Used for email notifications
EMAIL_NOTIFICATIONS_SERVER_USERNAME=username
EMAIL_NOTIFICATIONS_SERVER_PASSWORD=password
EMAIL_NOTIFICATIONS_SERVER_HOST=smtp.example.com
EMAIL_NOTIFICATIONS_SERVER_PORT=587
EMAIL_NOTIFICATIONS_FROM_EMAIL=noreply@example.com
EMAIL_NOTIFICATIONS_FROM_NAME="John Smith"
# Storage # Storage
# Used for uploading images, videos, etc... # Used for uploading images, videos, etc...

View File

@ -339,3 +339,10 @@ export const EyeIcon = (props: IconProps) => (
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
</Icon> </Icon>
) )
export const SendEmailIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</Icon>
)

View File

@ -13,6 +13,7 @@ import {
ImageIcon, ImageIcon,
NumberIcon, NumberIcon,
PhoneIcon, PhoneIcon,
SendEmailIcon,
TextIcon, TextIcon,
WebhookIcon, WebhookIcon,
} from 'assets/icons' } from 'assets/icons'
@ -61,7 +62,9 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
case IntegrationStepType.GOOGLE_ANALYTICS: case IntegrationStepType.GOOGLE_ANALYTICS:
return <GoogleAnalyticsLogo {...props} /> return <GoogleAnalyticsLogo {...props} />
case IntegrationStepType.WEBHOOK: case IntegrationStepType.WEBHOOK:
return <WebhookIcon /> return <WebhookIcon {...props} />
case IntegrationStepType.EMAIL:
return <SendEmailIcon {...props} />
case 'start': case 'start':
return <FlagIcon {...props} /> return <FlagIcon {...props} />
default: default:

View File

@ -51,6 +51,8 @@ export const StepTypeLabel = ({ type }: Props) => {
) )
case IntegrationStepType.WEBHOOK: case IntegrationStepType.WEBHOOK:
return <Text>Webhook</Text> return <Text>Webhook</Text>
case IntegrationStepType.EMAIL:
return <Text>Email</Text>
default: default:
return <></> return <></>
} }

View File

@ -11,14 +11,15 @@ import {
import { ChevronLeftIcon, PlusIcon } from 'assets/icons' import { ChevronLeftIcon, PlusIcon } from 'assets/icons'
import React, { useEffect, useMemo } from 'react' import React, { useEffect, useMemo } from 'react'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { CredentialsType } from 'db'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { CredentialsType } from 'models'
type Props = Omit<MenuButtonProps, 'type'> & { type Props = Omit<MenuButtonProps, 'type'> & {
type: CredentialsType type: CredentialsType
currentCredentialsId?: string currentCredentialsId?: string
onCredentialsSelect: (credentialId: string) => void onCredentialsSelect: (credentialId: string) => void
onCreateNewClick: () => void onCreateNewClick: () => void
defaultCredentialLabel?: string
} }
export const CredentialsDropdown = ({ export const CredentialsDropdown = ({
@ -26,11 +27,14 @@ export const CredentialsDropdown = ({
currentCredentialsId, currentCredentialsId,
onCredentialsSelect, onCredentialsSelect,
onCreateNewClick, onCreateNewClick,
defaultCredentialLabel,
...props ...props
}: Props) => { }: Props) => {
const router = useRouter() const router = useRouter()
const { credentials } = useUser() const { credentials } = useUser()
const defaultCredentialsLabel = defaultCredentialLabel ?? `Select an account`
const credentialsList = useMemo(() => { const credentialsList = useMemo(() => {
return credentials.filter((credential) => credential.type === type) return credentials.filter((credential) => credential.type === type)
}, [type, credentials]) }, [type, credentials])
@ -70,11 +74,22 @@ export const CredentialsDropdown = ({
{...props} {...props}
> >
<Text isTruncated overflowY="visible" h="20px"> <Text isTruncated overflowY="visible" h="20px">
{currentCredential ? currentCredential.name : 'Select an account'} {currentCredential ? currentCredential.name : defaultCredentialsLabel}
</Text> </Text>
</MenuButton> </MenuButton>
<MenuList maxW="500px"> <MenuList maxW="500px">
<Stack maxH={'35vh'} overflowY="scroll" spacing="0"> <Stack maxH={'35vh'} overflowY="scroll" spacing="0">
{defaultCredentialLabel && (
<MenuItem
maxW="500px"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
onClick={handleMenuItemClick('default')}
>
{defaultCredentialLabel}
</MenuItem>
)}
{credentialsList.map((credentials) => ( {credentialsList.map((credentials) => (
<MenuItem <MenuItem
key={credentials.id} key={credentials.id}

View File

@ -34,6 +34,7 @@ import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody' import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody' import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { RedirectSettings } from './bodies/RedirectSettings' import { RedirectSettings } from './bodies/RedirectSettings'
import { SendEmailSettings } from './bodies/SendEmailSettings/SendEmailSettings'
import { SetVariableSettings } from './bodies/SetVariableSettings' import { SetVariableSettings } from './bodies/SetVariableSettings'
import { WebhookSettings } from './bodies/WebhookSettings' import { WebhookSettings } from './bodies/WebhookSettings'
@ -213,6 +214,14 @@ export const StepSettings = ({
/> />
) )
} }
case IntegrationStepType.EMAIL: {
return (
<SendEmailSettings
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
default: { default: {
return <></> return <></>
} }

View File

@ -3,9 +3,9 @@ import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
import { DropdownList } from 'components/shared/DropdownList' import { DropdownList } from 'components/shared/DropdownList'
import { TableList, TableListItemProps } from 'components/shared/TableList' import { TableList, TableListItemProps } from 'components/shared/TableList'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { CredentialsType } from 'db'
import { import {
Cell, Cell,
CredentialsType,
ExtractingCell, ExtractingCell,
GoogleSheetsAction, GoogleSheetsAction,
GoogleSheetsGetOptions, GoogleSheetsGetOptions,

View File

@ -0,0 +1,92 @@
import { Stack, useDisclosure, Text } from '@chakra-ui/react'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
import {
InputWithVariableButton,
TextareaWithVariableButton,
} from 'components/shared/TextboxWithVariableButton'
import { CredentialsType, SendEmailOptions } from 'models'
import React from 'react'
import { SmtpConfigModal } from './SmtpConfigModal'
type Props = {
options: SendEmailOptions
onOptionsChange: (options: SendEmailOptions) => void
}
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const handleCredentialsSelect = (credentialsId: string) =>
onOptionsChange({
...options,
credentialsId,
})
const handleToChange = (recipientsStr: string) => {
const recipients: string[] = recipientsStr
.split(',')
.map((str) => str.trim())
onOptionsChange({
...options,
recipients,
})
}
const handleSubjectChange = (subject: string) =>
onOptionsChange({
...options,
subject,
})
const handleBodyChange = (body: string) =>
onOptionsChange({
...options,
body,
})
return (
<Stack spacing={4}>
<Stack>
<Text>From: </Text>
<CredentialsDropdown
type={CredentialsType.SMTP}
currentCredentialsId={options.credentialsId}
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
defaultCredentialLabel={
process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_EMAIL
}
/>
</Stack>
<Stack>
<Text>To: </Text>
<InputWithVariableButton
onChange={handleToChange}
initialValue={options.recipients.join(', ')}
placeholder="email1@gmail.com, email2@gmail.com"
/>
</Stack>
<Stack>
<Text>Subject: </Text>
<InputWithVariableButton
data-testid="subject-input"
onChange={handleSubjectChange}
initialValue={options.subject ?? ''}
/>
</Stack>
<Stack>
<Text>Body: </Text>
<TextareaWithVariableButton
data-testid="body-input"
minH="300px"
onChange={handleBodyChange}
initialValue={options.body ?? ''}
/>
</Stack>
<SmtpConfigModal
isOpen={isOpen}
onClose={onClose}
onNewCredentials={handleCredentialsSelect}
/>
</Stack>
)
}

View File

@ -0,0 +1,88 @@
import { FormControl, FormLabel, HStack, Stack } from '@chakra-ui/react'
import { isDefined } from '@udecode/plate-common'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { SmartNumberInput } from 'components/shared/SmartNumberInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { SmtpCredentialsData } from 'models'
import React from 'react'
type Props = {
config: SmtpCredentialsData
onConfigChange: (config: SmtpCredentialsData) => void
}
export const SmtpConfigForm = ({ config, onConfigChange }: Props) => {
const handleFromEmailChange = (email: string) =>
onConfigChange({ ...config, from: { ...config.from, email } })
const handleFromNameChange = (name: string) =>
onConfigChange({ ...config, from: { ...config.from, name } })
const handleHostChange = (host: string) => onConfigChange({ ...config, host })
const handleUsernameChange = (username: string) =>
onConfigChange({ ...config, username })
const handlePasswordChange = (password: string) =>
onConfigChange({ ...config, password })
const handleTlsCheck = (isTlsEnabled: boolean) =>
onConfigChange({ ...config, isTlsEnabled })
const handlePortNumberChange = (port?: number) =>
isDefined(port) && onConfigChange({ ...config, port })
return (
<Stack as="form" spacing={4}>
<FormControl isRequired>
<FormLabel>From email:</FormLabel>
<DebouncedInput
initialValue={config.from.email ?? ''}
onChange={handleFromEmailChange}
placeholder="notifications@provider.com"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>From name:</FormLabel>
<DebouncedInput
initialValue={config.from.name ?? ''}
onChange={handleFromNameChange}
placeholder="John Smith"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Host:</FormLabel>
<DebouncedInput
initialValue={config.host ?? ''}
onChange={handleHostChange}
placeholder="mail.provider.com"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Username / Email:</FormLabel>
<DebouncedInput
type="email"
initialValue={config.username ?? ''}
onChange={handleUsernameChange}
placeholder="user@provider.com"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Password:</FormLabel>
<DebouncedInput
type="password"
initialValue={config.password ?? ''}
onChange={handlePasswordChange}
/>
</FormControl>
<SwitchWithLabel
id="Tls"
label={'Use TLS?'}
initialValue={config.isTlsEnabled ?? false}
onCheckChange={handleTlsCheck}
/>
<FormControl as={HStack} justifyContent="space-between">
<FormLabel mb="0">Port number:</FormLabel>
<SmartNumberInput
placeholder="25"
value={config.port}
onValueChange={handlePortNumberChange}
/>
</FormControl>
</Stack>
)
}

View File

@ -0,0 +1,88 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
useToast,
} from '@chakra-ui/react'
import { useUser } from 'contexts/UserContext'
import { CredentialsType, SmtpCredentialsData } from 'models'
import React, { useState } from 'react'
import { createCredentials } from 'services/credentials'
import { isNotDefined } from 'utils'
import { SmtpConfigForm } from './SmtpConfigForm'
type Props = {
isOpen: boolean
onClose: () => void
onNewCredentials: (id: string) => void
}
export const SmtpConfigModal = ({
isOpen,
onNewCredentials,
onClose,
}: Props) => {
const { user, mutateCredentials } = useUser()
const [isCreating, setIsCreating] = useState(false)
const toast = useToast({
position: 'top-right',
status: 'error',
})
const [smtpConfig, setSmtpConfig] = useState<SmtpCredentialsData>({
from: {},
port: 25,
})
const handleCreateClick = async () => {
if (!user) return
setIsCreating(true)
const { data, error } = await createCredentials(user.id, {
data: smtpConfig,
name: smtpConfig.from.email as string,
type: CredentialsType.SMTP,
})
await mutateCredentials()
setIsCreating(false)
if (error) return toast({ title: error.name, description: error.message })
if (!data?.credentials)
return toast({ description: "Credentials wasn't created" })
onNewCredentials(data.credentials.id)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Create SMTP config</ModalHeader>
<ModalCloseButton />
<ModalBody>
<SmtpConfigForm config={smtpConfig} onConfigChange={setSmtpConfig} />
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
mr={3}
onClick={handleCreateClick}
isDisabled={
isNotDefined(smtpConfig.from.email) ||
isNotDefined(smtpConfig.from.name) ||
isNotDefined(smtpConfig.host) ||
isNotDefined(smtpConfig.username) ||
isNotDefined(smtpConfig.password)
}
isLoading={isCreating}
>
Create
</Button>
<Button variant="ghost">Close</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

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

View File

@ -20,6 +20,7 @@ import {
import { ConfigureContent } from './contents/ConfigureContent' import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent' import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PlaceholderContent } from './contents/PlaceholderContent' import { PlaceholderContent } from './contents/PlaceholderContent'
import { SendEmailContent } from './contents/SendEmailContent'
type Props = { type Props = {
step: Step | StartStep step: Step | StartStep
@ -101,6 +102,9 @@ export const StepNodeContent = ({ step, indices }: Props) => {
case IntegrationStepType.WEBHOOK: { case IntegrationStepType.WEBHOOK: {
return <WebhookContent step={step} /> return <WebhookContent step={step} />
} }
case IntegrationStepType.EMAIL: {
return <SendEmailContent step={step} />
}
case 'start': { case 'start': {
return <Text>Start</Text> return <Text>Start</Text>
} }

View File

@ -0,0 +1,23 @@
import { Tag, Text, Wrap, WrapItem } from '@chakra-ui/react'
import { SendEmailStep } from 'models'
type Props = {
step: SendEmailStep
}
export const SendEmailContent = ({ step }: Props) => {
if (step.options.recipients.length === 0)
return <Text color="gray.500">Configure...</Text>
return (
<Wrap isTruncated pr="6">
<WrapItem>
<Text>Send email to</Text>
</WrapItem>
{step.options.recipients.map((to) => (
<WrapItem key={to}>
<Tag>{to}</Tag>
</WrapItem>
))}
</Wrap>
)
}

View File

@ -107,7 +107,6 @@ export const SearchableDropdown = ({
<PopoverContent <PopoverContent
maxH="35vh" maxH="35vh"
overflowY="scroll" overflowY="scroll"
spacing="0"
role="menu" role="menu"
w="inherit" w="inherit"
shadow="lg" shadow="lg"

View File

@ -36,7 +36,7 @@ export const TextBoxWithVariableButton = ({
const [carretPosition, setCarretPosition] = useState<number>(0) const [carretPosition, setCarretPosition] = useState<number>(0)
useEffect(() => { useEffect(() => {
if (value !== initialValue) onChange(value) onChange(value)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]) }, [value])

View File

@ -123,7 +123,6 @@ export const VariableSearchInput = ({
<PopoverContent <PopoverContent
maxH="35vh" maxH="35vh"
overflowY="scroll" overflowY="scroll"
spacing="0"
role="menu" role="menu"
w="inherit" w="inherit"
shadow="lg" shadow="lg"

View File

@ -13,7 +13,9 @@ import { updateUser as updateUserInDb } from 'services/user'
import { useToast } from '@chakra-ui/react' import { useToast } from '@chakra-ui/react'
import { deepEqual } from 'fast-equals' import { deepEqual } from 'fast-equals'
import { useCredentials } from 'services/credentials' import { useCredentials } from 'services/credentials'
import { Credentials, User } from 'db' import { User } from 'db'
import { KeyedMutator } from 'swr'
import { Credentials } from 'models'
const userContext = createContext<{ const userContext = createContext<{
user?: User user?: User
@ -24,6 +26,9 @@ const userContext = createContext<{
credentials: Credentials[] credentials: Credentials[]
updateUser: (newUser: Partial<User>) => void updateUser: (newUser: Partial<User>) => void
saveUser: () => void saveUser: () => void
mutateCredentials: KeyedMutator<{
credentials: Credentials[]
}>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
}>({}) }>({})
@ -32,7 +37,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
const router = useRouter() const router = useRouter()
const { data: session, status } = useSession() const { data: session, status } = useSession()
const [user, setUser] = useState<User | undefined>() const [user, setUser] = useState<User | undefined>()
const { credentials } = useCredentials({ const { credentials, mutate } = useCredentials({
userId: user?.id, userId: user?.id,
onError: (error) => onError: (error) =>
toast({ title: error.name, description: error.message }), toast({ title: error.name, description: error.message }),
@ -94,6 +99,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
hasUnsavedChanges, hasUnsavedChanges,
isOAuthProvider, isOAuthProvider,
credentials: credentials ?? [], credentials: credentials ?? [],
mutateCredentials: mutate,
}} }}
> >
{children} {children}

View File

@ -1,5 +1,7 @@
import { Prisma, Credentials as CredentialsFromDb } from 'db' import { Credentials as CredentialsFromDb } from 'db'
import { OAuth2Client, Credentials } from 'google-auth-library' import { OAuth2Client } from 'google-auth-library'
import { GoogleSheetsCredentialsData } from 'models'
import { decrypt, encrypt } from 'utils'
import prisma from './prisma' import prisma from './prisma'
export const oauth2Client = new OAuth2Client( export const oauth2Client = new OAuth2Client(
@ -15,14 +17,21 @@ export const getAuthenticatedGoogleClient = async (
const credentials = (await prisma.credentials.findFirst({ const credentials = (await prisma.credentials.findFirst({
where: { id: credentialsId, ownerId: userId }, where: { id: credentialsId, ownerId: userId },
})) as CredentialsFromDb })) as CredentialsFromDb
oauth2Client.setCredentials(credentials.data as Credentials) const data = decrypt(
credentials.data,
credentials.iv
) as GoogleSheetsCredentialsData
oauth2Client.setCredentials(data)
oauth2Client.on('tokens', updateTokens(credentialsId)) oauth2Client.on('tokens', updateTokens(credentialsId))
return oauth2Client return oauth2Client
} }
const updateTokens = const updateTokens =
(credentialsId: string) => async (credentials: Credentials) => (credentialsId: string) =>
prisma.credentials.update({ async (credentials: GoogleSheetsCredentialsData) => {
const { encryptedData, iv } = encrypt(credentials)
return prisma.credentials.update({
where: { id: credentialsId }, where: { id: credentialsId },
data: { data: credentials as Prisma.InputJsonValue }, data: { data: encryptedData, iv },
}) })
}

View File

@ -11,14 +11,14 @@ import { NextApiRequest, NextApiResponse } from 'next'
const providers: Provider[] = [ const providers: Provider[] = [
EmailProvider({ EmailProvider({
server: { server: {
host: process.env.EMAIL_SERVER_HOST, host: process.env.AUTH_EMAIL_SERVER_HOST,
port: Number(process.env.EMAIL_SERVER_PORT), port: Number(process.env.AUTH_EMAIL_SERVER_PORT),
auth: { auth: {
user: process.env.EMAIL_SERVER_USER, user: process.env.AUTH_EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD, pass: process.env.AUTH_EMAIL_SERVER_PASSWORD,
}, },
}, },
from: process.env.EMAIL_FROM, from: `"${process.env.AUTH_EMAIL_FROM_NAME}" <${process.env.AUTH_EMAIL_FROM_EMAIL}>`,
}), }),
] ]

View File

@ -1,10 +1,12 @@
import { oauth2Client } from 'libs/google-sheets'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react' import { getSession } from 'next-auth/react'
import { CredentialsType, Prisma, User } from 'db' import { Prisma, User } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { googleSheetsScopes } from './consent-url' import { googleSheetsScopes } from './consent-url'
import { stringify } from 'querystring' import { stringify } from 'querystring'
import { CredentialsType } from 'models'
import { encrypt } from 'utils'
import { oauth2Client } from 'libs/google-sheets'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req }) const session = await getSession({ req })
@ -35,12 +37,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res return res
.status(400) .status(400)
.send({ message: "User didn't accepted required scopes" }) .send({ message: "User didn't accepted required scopes" })
const { encryptedData, iv } = encrypt(tokens)
const credentials = { const credentials = {
name: email, name: email,
type: CredentialsType.GOOGLE_SHEETS, type: CredentialsType.GOOGLE_SHEETS,
ownerId: user.id, ownerId: user.id,
data: tokens as Prisma.InputJsonValue, data: encryptedData,
} iv,
} as Prisma.CredentialsUncheckedCreateInput
const { id: credentialsId } = await prisma.credentials.upsert({ const { id: credentialsId } = await prisma.credentials.upsert({
create: credentials, create: credentials,
update: credentials, update: credentials,

View File

@ -47,17 +47,17 @@ const executeWebhook = async (
const contentType = headers ? headers['Content-Type'] : undefined const contentType = headers ? headers['Content-Type'] : undefined
try { try {
const response = await got( const response = await got(
parseVariables({ text: webhook.url + `?${queryParams}`, variables }), parseVariables(variables)(webhook.url + `?${queryParams}`),
{ {
method: webhook.method as Method, method: webhook.method as Method,
headers, headers,
json: json:
contentType !== 'x-www-form-urlencoded' && webhook.body contentType !== 'x-www-form-urlencoded' && webhook.body
? JSON.parse(parseVariables({ text: webhook.body, variables })) ? JSON.parse(parseVariables(variables)(webhook.body))
: undefined, : undefined,
form: form:
contentType === 'x-www-form-urlencoded' && webhook.body contentType === 'x-www-form-urlencoded' && webhook.body
? JSON.parse(parseVariables({ text: webhook.body, variables })) ? JSON.parse(parseVariables(variables)(webhook.body))
: undefined, : undefined,
} }
) )
@ -96,7 +96,7 @@ const convertKeyValueTableToObject = (
if (!item.key) return {} if (!item.key) return {}
return { return {
...object, ...object,
[item.key]: parseVariables({ text: item.value, variables }), [item.key]: parseVariables(variables)(item.value ?? ''),
} }
}, {}) }, {})
} }

View File

@ -1,8 +1,9 @@
import { User } from 'db' import { Prisma, User } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { Credentials } from 'models'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react' import { getSession } from 'next-auth/react'
import { methodNotAllowed } from 'utils' import { encrypt, methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req }) const session = await getSession({ req })
@ -16,6 +17,20 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') { if (req.method === 'GET') {
const credentials = await prisma.credentials.findMany({ const credentials = await prisma.credentials.findMany({
where: { ownerId: user.id }, where: { ownerId: user.id },
select: { name: true, type: true, ownerId: true, id: true },
})
return res.send({ credentials })
}
if (req.method === 'POST') {
const data = JSON.parse(req.body) as Omit<Credentials, 'ownerId'>
const { encryptedData, iv } = encrypt(data.data)
const credentials = await prisma.credentials.create({
data: {
...data,
data: encryptedData,
iv,
ownerId: user.id,
} as Prisma.CredentialsUncheckedCreateInput,
}) })
return res.send({ credentials }) return res.send({ credentials })
} }

View File

@ -9,7 +9,7 @@ const config: PlaywrightTestConfig = {
timeout: 5000, timeout: 5000,
}, },
retries: process.env.NO_RETRIES ? 0 : 2, retries: process.env.NO_RETRIES ? 0 : 2,
workers: process.env.CI ? 1 : 3, workers: process.env.CI ? 1 : undefined,
reporter: 'html', reporter: 'html',
maxFailures: process.env.CI ? 10 : undefined, maxFailures: process.env.CI ? 10 : undefined,
use: { use: {

View File

@ -2,4 +2,11 @@ PLAYWRIGHT_BUILDER_TEST_BASE_URL=http://localhost:3000
# For auth # For auth
GITHUB_EMAIL= GITHUB_EMAIL=
GITHUB_PASSWORD= GITHUB_PASSWORD=
# SMTP Credentials (Generated on https://ethereal.email/)
SMTP_HOST=smtp.ethereal.email
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USERNAME=tobin.tillman65@ethereal.email
SMTP_PASSWORD=Ty9BcwCBrK6w8AG2hx

View File

@ -0,0 +1,112 @@
{
"id": "ckzcj4tfu1686gg1ae4fdj8uv",
"createdAt": "2022-02-07T10:06:35.274Z",
"updatedAt": "2022-02-07T10:06:35.274Z",
"name": "My typebot",
"ownerId": "ckz6t9iep0006k31a22j05fwq",
"publishedTypebotId": null,
"folderId": null,
"blocks": [
{
"id": "kSDJqC9TmM25eAM3a2yn3o",
"steps": [
{
"id": "phSmjJU2gYq7b11hpima8b",
"type": "start",
"label": "Start",
"blockId": "kSDJqC9TmM25eAM3a2yn3o",
"outgoingEdgeId": "vKtpPmbmqgeGC4vwCfPEdv"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "b5r2MMyftV1nv9vyr6VkZh",
"graphCoordinates": { "x": 242, "y": 174 },
"title": "Block #2",
"steps": [
{
"id": "sb7ibhNAKfvs8yy8fz3XRMT",
"blockId": "b5r2MMyftV1nv9vyr6VkZh",
"type": "text",
"content": {
"html": "<div>Send email</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Send email" }] }
],
"plainText": "Send email"
}
},
{
"id": "svM58drFcdtdJ7DaJCfTLXm",
"blockId": "b5r2MMyftV1nv9vyr6VkZh",
"type": "choice input",
"options": { "buttonLabel": "Send", "isMultipleChoice": false },
"items": [
{
"id": "nxQEmdaQXc9eFjrbrVBavH",
"stepId": "svM58drFcdtdJ7DaJCfTLXm",
"type": 0,
"content": "Go"
}
],
"outgoingEdgeId": "ioB4s1iRBb8wXiRam8Pp4s"
}
]
},
{
"id": "6jr7XM9GbVkJ2Ru1WyL45v",
"graphCoordinates": { "x": 609, "y": 429 },
"title": "Block #2",
"steps": [
{
"id": "sr2sdAzN5dGao1gCiDWCG8i",
"blockId": "6jr7XM9GbVkJ2Ru1WyL45v",
"type": "Email",
"options": { "credentialsId": "default", "recipients": [] }
}
]
}
],
"variables": [],
"edges": [
{
"from": {
"blockId": "kSDJqC9TmM25eAM3a2yn3o",
"stepId": "phSmjJU2gYq7b11hpima8b"
},
"to": { "blockId": "b5r2MMyftV1nv9vyr6VkZh" },
"id": "vKtpPmbmqgeGC4vwCfPEdv"
},
{
"from": {
"blockId": "b5r2MMyftV1nv9vyr6VkZh",
"stepId": "svM58drFcdtdJ7DaJCfTLXm"
},
"to": { "blockId": "6jr7XM9GbVkJ2Ru1WyL45v" },
"id": "ioB4s1iRBb8wXiRam8Pp4s"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": { "isBrandingEnabled": true },
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -1,5 +1,6 @@
import { import {
Block, Block,
CredentialsType,
defaultSettings, defaultSettings,
defaultTheme, defaultTheme,
PublicBlock, PublicBlock,
@ -7,8 +8,9 @@ import {
Step, Step,
Typebot, Typebot,
} from 'models' } from 'models'
import { CredentialsType, DashboardFolder, PrismaClient, User } from 'db' import { DashboardFolder, PrismaClient, User } from 'db'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { encrypt } from 'utils'
const prisma = new PrismaClient() const prisma = new PrismaClient()
@ -48,24 +50,27 @@ export const createFolders = (partialFolders: Partial<DashboardFolder>[]) =>
})), })),
}) })
const createCredentials = () => const createCredentials = () => {
prisma.credentials.createMany({ const { encryptedData, iv } = encrypt({
expiry_date: 1642441058842,
access_token:
'ya29.A0ARrdaM--PV_87ebjywDJpXKb77NBFJl16meVUapYdfNv6W6ZzqqC47fNaPaRjbDbOIIcp6f49cMaX5ndK9TAFnKwlVqz3nrK9nLKqgyDIhYsIq47smcAIZkK56SWPx3X3DwAFqRu2UPojpd2upWwo-3uJrod',
// This token is linked to a mock Google account (typebot.test.user@gmail.com)
refresh_token:
'1//0379tIHBxszeXCgYIARAAGAMSNwF-L9Ir0zhkzhblwXqn3_jYqRP3pajcUpqkjRU3fKZZ_eQakOa28amUHSQ-Q9fMzk89MpRTvkc',
})
return prisma.credentials.createMany({
data: [ data: [
{ {
name: 'test2@gmail.com', name: 'test2@gmail.com',
ownerId: process.env.PLAYWRIGHT_USER_ID as string, ownerId: process.env.PLAYWRIGHT_USER_ID as string,
type: CredentialsType.GOOGLE_SHEETS, type: CredentialsType.GOOGLE_SHEETS,
data: { data: encryptedData,
expiry_date: 1642441058842, iv,
access_token:
'ya29.A0ARrdaM--PV_87ebjywDJpXKb77NBFJl16meVUapYdfNv6W6ZzqqC47fNaPaRjbDbOIIcp6f49cMaX5ndK9TAFnKwlVqz3nrK9nLKqgyDIhYsIq47smcAIZkK56SWPx3X3DwAFqRu2UPojpd2upWwo-3uJrod',
// This token is linked to a mock Google account (typebot.test.user@gmail.com)
refresh_token:
'1//0379tIHBxszeXCgYIARAAGAMSNwF-L9Ir0zhkzhblwXqn3_jYqRP3pajcUpqkjRU3fKZZ_eQakOa28amUHSQ-Q9fMzk89MpRTvkc',
},
}, },
], ],
}) })
}
export const updateUser = (data: Partial<User>) => export const updateUser = (data: Partial<User>) =>
prisma.user.update({ prisma.user.update({

View File

@ -0,0 +1,80 @@
import test, { expect } from '@playwright/test'
import { importTypebotInDatabase } from '../../services/database'
import path from 'path'
import { generate } from 'short-uuid'
import { typebotViewer } from '../../services/selectorUtils'
const typebotId = generate()
test.describe('Send email step', () => {
test('its configuration should work', async ({ page }) => {
if (
!process.env.SMTP_USERNAME ||
!process.env.SMTP_PORT ||
!process.env.SMTP_SECURE ||
!process.env.SMTP_HOST ||
!process.env.SMTP_PASSWORD
)
throw new Error('SMTP_ env vars are missing')
await importTypebotInDatabase(
path.join(
__dirname,
'../../fixtures/typebots/integrations/sendEmail.json'
),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.click(
`text=${process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_EMAIL}`
)
await page.click('text=Connect new')
const createButton = page.locator('button >> text=Create')
await expect(createButton).toBeDisabled()
await page.fill(
'[placeholder="notifications@provider.com"]',
process.env.SMTP_USERNAME
)
await page.fill('[placeholder="John Smith"]', 'John Smith')
await page.fill('[placeholder="mail.provider.com"]', process.env.SMTP_HOST)
await page.fill(
'[placeholder="user@provider.com"]',
process.env.SMTP_USERNAME
)
await page.fill('[type="password"]', process.env.SMTP_PASSWORD)
if (process.env.SMTP_SECURE === 'true') await page.click('text=Use TLS?')
await page.fill('input[role="spinbutton"]', process.env.SMTP_PORT)
await expect(createButton).toBeEnabled()
await createButton.click()
await expect(
page.locator(`button >> text=${process.env.SMTP_USERNAME}`)
).toBeVisible()
await page.fill(
'[placeholder="email1@gmail.com, email2@gmail.com"]',
'email1@gmail.com, email2@gmail.com'
)
await expect(page.locator('span >> text=email1@gmail.com')).toBeVisible()
await expect(page.locator('span >> text=email2@gmail.com')).toBeVisible()
await page.fill(
'[placeholder="email1@gmail.com, email2@gmail.com"]',
'email1@gmail.com, email2@gmail.com'
)
await page.fill('[data-testid="subject-input"]', 'Email subject')
await page.fill('[data-testid="body-input"]', 'Here is my email')
await page.click('text=Preview')
await typebotViewer(page).locator('text=Go').click()
await page.waitForResponse(
(resp) =>
resp.request().url().includes('/api/integrations/email') &&
resp.status() === 200 &&
resp.request().method() === 'POST'
)
})
})

View File

@ -1,5 +1,6 @@
import { Credentials } from 'db' import { Credentials } from 'models'
import useSWR from 'swr' import useSWR from 'swr'
import { sendRequest } from 'utils'
import { fetcher } from './utils' import { fetcher } from './utils'
export const useCredentials = ({ export const useCredentials = ({
@ -20,3 +21,15 @@ export const useCredentials = ({
mutate, mutate,
} }
} }
export const createCredentials = async (
userId: string,
credentials: Omit<Credentials, 'ownerId' | 'id' | 'iv'>
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/users/${userId}/credentials`,
method: 'POST',
body: credentials,
})

View File

@ -34,6 +34,7 @@ import {
Item, Item,
ItemType, ItemType,
defaultConditionContent, defaultConditionContent,
defaultSendEmailOptions,
} from 'models' } from 'models'
import shortId, { generate } from 'short-uuid' import shortId, { generate } from 'short-uuid'
import { Typebot } from 'models' import { Typebot } from 'models'
@ -207,6 +208,8 @@ const parseDefaultStepOptions = (type: StepWithOptionsType): StepOptions => {
return defaultGoogleAnalyticsOptions return defaultGoogleAnalyticsOptions
case IntegrationStepType.WEBHOOK: case IntegrationStepType.WEBHOOK:
return defaultWebhookOptions return defaultWebhookOptions
case IntegrationStepType.EMAIL:
return defaultSendEmailOptions
} }
} }

View File

@ -1,5 +1,7 @@
import { Prisma, Credentials as CredentialsFromDb } from 'db' import { Credentials as CredentialsFromDb } from 'db'
import { OAuth2Client, Credentials } from 'google-auth-library' import { OAuth2Client, Credentials } from 'google-auth-library'
import { GoogleSheetsCredentialsData } from 'models'
import { decrypt, encrypt } from 'utils'
import prisma from './prisma' import prisma from './prisma'
export const oauth2Client = new OAuth2Client( export const oauth2Client = new OAuth2Client(
@ -14,14 +16,20 @@ export const getAuthenticatedGoogleClient = async (
const credentials = (await prisma.credentials.findFirst({ const credentials = (await prisma.credentials.findFirst({
where: { id: credentialsId }, where: { id: credentialsId },
})) as CredentialsFromDb })) as CredentialsFromDb
oauth2Client.setCredentials(credentials.data as Credentials) const data = decrypt(
credentials.data,
credentials.iv
) as GoogleSheetsCredentialsData
oauth2Client.setCredentials(data)
oauth2Client.on('tokens', updateTokens(credentialsId)) oauth2Client.on('tokens', updateTokens(credentialsId))
return oauth2Client return oauth2Client
} }
const updateTokens = const updateTokens =
(credentialsId: string) => async (credentials: Credentials) => (credentialsId: string) => async (credentials: Credentials) => {
prisma.credentials.update({ const { encryptedData, iv } = encrypt(credentials)
return prisma.credentials.update({
where: { id: credentialsId }, where: { id: credentialsId },
data: { data: credentials as Prisma.InputJsonValue }, data: { data: encryptedData, iv },
}) })
}

View File

@ -15,6 +15,7 @@
"google-spreadsheet": "^3.2.0", "google-spreadsheet": "^3.2.0",
"models": "*", "models": "*",
"next": "^12.0.7", "next": "^12.0.7",
"nodemailer": "^6.7.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"utils": "*" "utils": "*"
@ -23,6 +24,7 @@
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/google-spreadsheet": "^3.1.5", "@types/google-spreadsheet": "^3.1.5",
"@types/node": "^17.0.4", "@types/node": "^17.0.4",
"@types/nodemailer": "^6.4.4",
"@types/react": "^17.0.38", "@types/react": "^17.0.38",
"@typescript-eslint/eslint-plugin": "^5.9.0", "@typescript-eslint/eslint-plugin": "^5.9.0",
"eslint": "<8.0.0", "eslint": "<8.0.0",

View File

@ -0,0 +1,68 @@
import prisma from 'libs/prisma'
import { SendEmailOptions, SmtpCredentialsData } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { createTransport } from 'nodemailer'
import { Options } from 'nodemailer/lib/smtp-transport'
import { decrypt, initMiddleware } from 'utils'
import Cors from 'cors'
const cors = initMiddleware(Cors())
const defaultTransportOptions: Options = {
host: process.env.EMAIL_NOTIFICATIONS_SERVER_HOST,
port: Number(process.env.EMAIL_NOTIFICATIONS_SERVER_PORT),
secure: false,
auth: {
user: process.env.EMAIL_NOTIFICATIONS_SERVER_USER,
pass: process.env.EMAIL_NOTIFICATIONS_SERVER_PASSWORD,
},
}
const defaultFrom = `"${process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_NAME}" <${process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_EMAIL}>`
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
if (req.method === 'POST') {
const { credentialsId, recipients, body, subject } = JSON.parse(
req.body
) as SendEmailOptions
const credentials = await prisma.credentials.findUnique({
where: { id: credentialsId },
})
if (!credentials)
return res.status(404).send({ message: "Couldn't find credentials" })
const { host, port, isTlsEnabled, username, password, from } = decrypt(
credentials.data,
credentials.iv
) as SmtpCredentialsData
const transporter = createTransport(
credentialsId === 'default'
? defaultTransportOptions
: {
host,
port,
secure: isTlsEnabled ?? undefined,
auth: {
user: username,
pass: password,
},
}
)
const info = await transporter.sendMail({
from:
credentialsId === 'default'
? defaultFrom
: `"${from.name}" <${from.email}>`,
to: recipients.join(', '),
subject,
text: body,
})
res.status(200).send({ message: 'Email sent!', info })
}
}
export default handler

View File

@ -22,8 +22,7 @@ export const ImageBubble = ({ step, onTransitionEnd }: Props) => {
const [isTyping, setIsTyping] = useState(true) const [isTyping, setIsTyping] = useState(true)
const url = useMemo( const url = useMemo(
() => () => parseVariables(typebot.variables)(step.content?.url),
parseVariables({ text: step.content?.url, variables: typebot.variables }),
[step.content?.url, typebot.variables] [step.content?.url, typebot.variables]
) )

View File

@ -26,8 +26,7 @@ export const TextBubble = ({ step, onTransitionEnd }: Props) => {
const [isTyping, setIsTyping] = useState(true) const [isTyping, setIsTyping] = useState(true)
const content = useMemo( const content = useMemo(
() => () => parseVariables(typebot.variables)(step.content.html),
parseVariables({ text: step.content.html, variables: typebot.variables }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[typebot.variables] [typebot.variables]
) )

View File

@ -85,7 +85,7 @@ const VideoContent = ({
variables: Variable[] variables: Variable[]
}) => { }) => {
const url = useMemo( const url = useMemo(
() => parseVariables({ text: content?.url, variables: variables }), () => parseVariables(variables)(content?.url),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[variables] [variables]
) )

View File

@ -10,6 +10,7 @@ import {
GoogleSheetsGetOptions, GoogleSheetsGetOptions,
GoogleAnalyticsStep, GoogleAnalyticsStep,
WebhookStep, WebhookStep,
SendEmailStep,
} from 'models' } from 'models'
import { stringify } from 'qs' import { stringify } from 'qs'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
@ -25,7 +26,7 @@ export const executeIntegration = (
variables: Variable[], variables: Variable[],
indices: Indices, indices: Indices,
updateVariableValue: (variableId: string, value: string) => void updateVariableValue: (variableId: string, value: string) => void
) => { ): Promise<string | undefined> => {
switch (step.type) { switch (step.type) {
case IntegrationStepType.GOOGLE_SHEETS: case IntegrationStepType.GOOGLE_SHEETS:
return executeGoogleSheetIntegration(step, variables, updateVariableValue) return executeGoogleSheetIntegration(step, variables, updateVariableValue)
@ -39,6 +40,8 @@ export const executeIntegration = (
indices, indices,
updateVariableValue updateVariableValue
) )
case IntegrationStepType.EMAIL:
return sendEmail(step, variables)
} }
} }
@ -46,10 +49,11 @@ export const executeGoogleAnalyticsIntegration = async (
step: GoogleAnalyticsStep, step: GoogleAnalyticsStep,
variables: Variable[] variables: Variable[]
) => { ) => {
if (!step.options?.trackingId) return if (!step.options?.trackingId) return step.outgoingEdgeId
const { default: initGoogleAnalytics } = await import('../../lib/gtag') const { default: initGoogleAnalytics } = await import('../../lib/gtag')
await initGoogleAnalytics(step.options.trackingId) await initGoogleAnalytics(step.options.trackingId)
sendGaEvent(parseVariablesInObject(step.options, variables)) sendGaEvent(parseVariablesInObject(step.options, variables))
return step.outgoingEdgeId
} }
const executeGoogleSheetIntegration = async ( const executeGoogleSheetIntegration = async (
@ -100,10 +104,7 @@ const updateRowInGoogleSheets = async (
values: parseCellValues(options.cellsToUpsert, variables), values: parseCellValues(options.cellsToUpsert, variables),
referenceCell: { referenceCell: {
column: options.referenceCell.column, column: options.referenceCell.column,
value: parseVariables({ value: parseVariables(variables)(options.referenceCell.value ?? ''),
text: options.referenceCell.value ?? '',
variables,
}),
}, },
}, },
}) })
@ -120,10 +121,7 @@ const getRowFromGoogleSheets = async (
credentialsId: options.credentialsId, credentialsId: options.credentialsId,
referenceCell: { referenceCell: {
column: options.referenceCell.column, column: options.referenceCell.column,
value: parseVariables({ value: parseVariables(variables)(options.referenceCell.value ?? ''),
text: options.referenceCell.value ?? '',
variables,
}),
}, },
columns: options.cellsToExtract.map((cell) => cell.column), columns: options.cellsToExtract.map((cell) => cell.column),
}, },
@ -147,7 +145,7 @@ const parseCellValues = (
? row ? row
: { : {
...row, ...row,
[cell.column]: parseVariables({ text: cell.value, variables }), [cell.column]: parseVariables(variables)(cell.value),
} }
}, {}) }, {})
@ -174,3 +172,19 @@ const executeWebhook = async (
updateVariableValue(varMapping.variableId, value) updateVariableValue(varMapping.variableId, value)
}) })
} }
const sendEmail = async (step: SendEmailStep, variables: Variable[]) => {
const { options } = step
const { error } = await sendRequest({
url: `http://localhost:3001/api/integrations/email`,
method: 'POST',
body: {
credentialsId: options.credentialsId,
recipients: options.recipients.map(parseVariables(variables)),
subject: parseVariables(variables)(options.subject ?? ''),
body: parseVariables(variables)(options.body ?? ''),
},
})
console.error(error)
return step.outgoingEdgeId
}

View File

@ -39,7 +39,7 @@ const executeSetVariable = (
return step.outgoingEdgeId return step.outgoingEdgeId
const expression = step.options.expressionToEvaluate const expression = step.options.expressionToEvaluate
const evaluatedExpression = isMathFormula(expression) const evaluatedExpression = isMathFormula(expression)
? evaluateExpression(parseVariables({ text: expression, variables })) ? evaluateExpression(parseVariables(variables)(expression))
: expression : expression
updateVariableValue(step.options.variableId, evaluatedExpression) updateVariableValue(step.options.variableId, evaluatedExpression)
return step.outgoingEdgeId return step.outgoingEdgeId
@ -92,7 +92,7 @@ const executeRedirect = (
): EdgeId | undefined => { ): EdgeId | undefined => {
if (!step.options?.url) return step.outgoingEdgeId if (!step.options?.url) return step.outgoingEdgeId
window.open( window.open(
sanitizeUrl(parseVariables({ text: step.options?.url, variables })), sanitizeUrl(parseVariables(variables)(step.options?.url)),
step.options.isNewTab ? '_blank' : '_self' step.options.isNewTab ? '_blank' : '_self'
) )
return step.outgoingEdgeId return step.outgoingEdgeId

View File

@ -6,23 +6,19 @@ const safeEval = eval
export const stringContainsVariable = (str: string): boolean => export const stringContainsVariable = (str: string): boolean =>
/\{\{(.*?)\}\}/g.test(str) /\{\{(.*?)\}\}/g.test(str)
export const parseVariables = ({ export const parseVariables =
text, (variables: Variable[]) =>
variables, (text?: string): string => {
}: { if (!text || text === '') return ''
text?: string return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => {
variables: Variable[] const matchedVarName = fullVariableString.replace(/{{|}}/g, '')
}): string => { return (
if (!text || text === '') return '' variables.find((v) => {
return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => { return matchedVarName === v.name && isDefined(v.value)
const matchedVarName = fullVariableString.replace(/{{|}}/g, '') })?.value ?? ''
return ( )
variables.find((v) => { })
return matchedVarName === v.name && isDefined(v.value) }
})?.value ?? ''
)
})
}
export const isMathFormula = (str?: string) => export const isMathFormula = (str?: string) =>
['*', '/', '+', '-'].some((val) => str && str.includes(val)) ['*', '/', '+', '-'].some((val) => str && str.includes(val))
@ -58,7 +54,7 @@ export const parseVariablesInObject = (
...newObj, ...newObj,
[key]: [key]:
typeof currentValue === 'string' typeof currentValue === 'string'
? parseVariables({ text: currentValue, variables }) ? parseVariables(variables)(currentValue)
: currentValue, : currentValue,
} }
}, {}) }, {})

View File

@ -56,17 +56,14 @@ model Credentials {
id String @id @default(cuid()) id String @id @default(cuid())
ownerId String ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
data Json data String // Encrypted data
name String name String
type CredentialsType type String
iv String
@@unique([name, type, ownerId]) @@unique([name, type, ownerId])
} }
enum CredentialsType {
GOOGLE_SHEETS
}
enum Plan { enum Plan {
FREE FREE
PRO PRO

View File

@ -0,0 +1,38 @@
import { Credentials as CredentialsFromPrisma } from 'db'
export type Credentials = SmtpCredentials | GoogleSheetsCredentials
export type CredentialsBase = Omit<CredentialsFromPrisma, 'data' | 'type'>
export enum CredentialsType {
GOOGLE_SHEETS = 'google sheets',
SMTP = 'smtp',
}
export type SmtpCredentials = CredentialsBase & {
type: CredentialsType.SMTP
data: SmtpCredentialsData
}
export type GoogleSheetsCredentials = CredentialsBase & {
type: CredentialsType.GOOGLE_SHEETS
data: GoogleSheetsCredentialsData
}
export type GoogleSheetsCredentialsData = {
refresh_token?: string | null
expiry_date?: number | null
access_token?: string | null
token_type?: string | null
id_token?: string | null
scope?: string
}
export type SmtpCredentialsData = {
host?: string
username?: string
password?: string
isTlsEnabled?: boolean
port: number
from: { email?: string; name?: string }
}

View File

@ -3,3 +3,4 @@ export * from './publicTypebot'
export * from './result' export * from './result'
export * from './answer' export * from './answer'
export * from './utils' export * from './utils'
export * from './credentials'

View File

@ -4,16 +4,19 @@ export type IntegrationStep =
| GoogleSheetsStep | GoogleSheetsStep
| GoogleAnalyticsStep | GoogleAnalyticsStep
| WebhookStep | WebhookStep
| SendEmailStep
export type IntegrationStepOptions = export type IntegrationStepOptions =
| GoogleSheetsOptions | GoogleSheetsOptions
| GoogleAnalyticsOptions | GoogleAnalyticsOptions
| WebhookOptions | WebhookOptions
| SendEmailOptions
export enum IntegrationStepType { export enum IntegrationStepType {
GOOGLE_SHEETS = 'Google Sheets', GOOGLE_SHEETS = 'Google Sheets',
GOOGLE_ANALYTICS = 'Google Analytics', GOOGLE_ANALYTICS = 'Google Analytics',
WEBHOOK = 'Webhook', WEBHOOK = 'Webhook',
EMAIL = 'Email',
} }
export type GoogleSheetsStep = StepBase & { export type GoogleSheetsStep = StepBase & {
@ -32,6 +35,18 @@ export type WebhookStep = StepBase & {
webhook: Webhook webhook: Webhook
} }
export type SendEmailStep = StepBase & {
type: IntegrationStepType.EMAIL
options: SendEmailOptions
}
export type SendEmailOptions = {
credentialsId: string | 'default'
recipients: string[]
subject?: string
body?: string
}
export type GoogleAnalyticsOptions = { export type GoogleAnalyticsOptions = {
trackingId?: string trackingId?: string
category?: string category?: string
@ -142,3 +157,8 @@ export const defaultWebhookAttributes: Omit<Webhook, 'id'> = {
headers: [], headers: [],
queryParams: [], queryParams: [],
} }
export const defaultSendEmailOptions: SendEmailOptions = {
credentialsId: 'default',
recipients: [],
}

View File

@ -7,7 +7,6 @@ import {
RedirectStep, RedirectStep,
SetVariableStep, SetVariableStep,
} from '.' } from '.'
import { Edge } from '..'
import { BubbleStep, BubbleStepType } from './bubble' import { BubbleStep, BubbleStepType } from './bubble'
import { InputStep, InputStepType } from './inputs' import { InputStep, InputStepType } from './inputs'
import { IntegrationStep } from './integration' import { IntegrationStep } from './integration'

View File

@ -25,7 +25,7 @@ export default [
], ],
plugins: [ plugins: [
peerDepsExternal(), peerDepsExternal(),
resolve(), resolve({ preferBuiltins: true }),
commonjs(), commonjs(),
typescript({ tsconfig: './tsconfig.json' }), typescript({ tsconfig: './tsconfig.json' }),
], ],

View File

@ -0,0 +1,36 @@
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'
const algorithm = 'aes-256-gcm'
const secretKey = process.env.SECRET
export const encrypt = (
data: object
): { encryptedData: string; iv: string } => {
if (!secretKey) throw new Error(`SECRET is not in environment`)
const iv = randomBytes(16)
const cipher = createCipheriv(algorithm, secretKey, iv)
const dataString = JSON.stringify(data)
const encryptedData =
cipher.update(dataString, 'utf8', 'hex') + cipher.final('hex')
const tag = cipher.getAuthTag()
return {
encryptedData,
iv: iv.toString('hex') + '.' + tag.toString('hex'),
}
}
export const decrypt = (encryptedData: string, auth: string): object => {
if (!secretKey) throw new Error(`SECRET is not in environment`)
const [iv, tag] = auth.split('.')
const decipher = createDecipheriv(
algorithm,
secretKey,
Buffer.from(iv, 'hex')
)
decipher.setAuthTag(Buffer.from(tag, 'hex'))
return JSON.parse(
(
decipher.update(Buffer.from(encryptedData, 'hex')) + decipher.final('hex')
).toString()
)
}

View File

@ -1,2 +1,3 @@
export * from './utils' export * from './utils'
export * from './apiUtils' export * from './apiUtils'
export * from './encryption'

3755
yarn.lock

File diff suppressed because it is too large Load Diff