2
0

🧑‍💻 (emails) Add decent emails management

Use mjml-react to generate emails. Put all emails in a independent package.
This commit is contained in:
Baptiste Arnaud
2022-10-01 07:00:05 +02:00
parent e1f2d49342
commit 1654de3c1f
50 changed files with 4811 additions and 4048 deletions

View File

@ -0,0 +1,26 @@
import React from 'react'
import { MjmlButton } from '@faire/mjml-react'
import { blue, grayLight } from '../theme'
import { leadingTight, textBase, borderBase } from '../theme'
type ButtonProps = {
link: string
children: React.ReactNode
}
export const Button = ({ link, children }: ButtonProps) => (
<MjmlButton
lineHeight={leadingTight}
fontSize={textBase}
fontWeight="700"
height={32}
padding="0"
align="left"
href={link}
backgroundColor={blue}
color={grayLight}
borderRadius={borderBase}
>
{children}
</MjmlButton>
)

View File

@ -0,0 +1,81 @@
import React, { ReactElement } from 'react'
import {
MjmlHead,
MjmlFont,
MjmlAttributes,
MjmlAll,
MjmlStyle,
MjmlRaw,
} from '@faire/mjml-react'
import { black, grayDark } from '../theme'
type HeadProps = { children?: ReactElement }
export const Head = ({ children }: HeadProps) => (
<MjmlHead>
<>
<MjmlRaw>
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light" />
</MjmlRaw>
<MjmlFont
name="Inter"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700"
/>
<MjmlStyle>{`
strong {
font-weight: 700;
}
.smooth {
-webkit-font-smoothing: antialiased;
}
.paragraph a {
color: ${black} !important;
}
.li {
text-indent: -18px;
margin-left: 24px;
display: inline-block;
}
.footer a {
text-decoration: none !important;
color: ${grayDark} !important;
}
.dark-mode {
display: none;
}
@media (min-width:480px) {
td.hero {
padding-left: 24px !important;
padding-right: 24px !important;
}
}
@media (prefers-color-scheme: dark) {
body {
background: ${black};
}
.logo > * {
filter: invert(1) !important;
}
.paragraph > *, .paragraph a, .li > div {
color: #fff !important;
}
.dark-mode {
display: inherit;
}
.light-mode {
display: none;
}
}
`}</MjmlStyle>
<MjmlAttributes>
<MjmlAll
font-family='-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'
font-weight="400"
/>
</MjmlAttributes>
{children}
</>
</MjmlHead>
)

View File

@ -0,0 +1,15 @@
import { MjmlImageProps, MjmlImage } from '@faire/mjml-react'
import React from 'react'
import { borderBase } from '../theme'
export const HeroImage = (props: MjmlImageProps) => (
<MjmlImage
cssClass="hero"
padding="0"
align="left"
borderRadius={borderBase}
{...props}
>
{props.children}
</MjmlImage>
)

View File

@ -0,0 +1,15 @@
import { MjmlText, MjmlTextProps, PaddingProps } from '@faire/mjml-react'
import React from 'react'
import { leadingRelaxed, textBase } from '../theme'
export const Text = (props: MjmlTextProps & PaddingProps) => (
<MjmlText
padding="24px 0 0"
fontSize={textBase}
lineHeight={leadingRelaxed}
cssClass="paragraph"
{...props}
>
{props.children}
</MjmlText>
)

View File

@ -0,0 +1,4 @@
export { Button } from './Button'
export { Text } from './Text'
export { HeroImage } from './HeroImage'
export { Head } from './Head'

View File

@ -0,0 +1,78 @@
import React, { ComponentProps } from 'react'
import {
Mjml,
MjmlBody,
MjmlSection,
MjmlColumn,
MjmlSpacer,
render,
} from '@faire/mjml-react'
import { Button, Head, HeroImage, Text } from '../components'
import { parseNumberWithCommas } from 'utils'
import { SendMailOptions } from 'nodemailer'
import { sendEmail } from '../sendEmail'
type AlmostReachedChatsLimitEmailProps = {
chatsLimit: number
url: string
}
const now = new Date()
const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
const readableResetDate = firstDayOfNextMonth
.toDateString()
.split(' ')
.slice(1, 4)
.join(' ')
export const AlmostReachedChatsLimitEmail = ({
chatsLimit,
url,
}: AlmostReachedChatsLimitEmailProps) => {
const readableChatsLimit = parseNumberWithCommas(chatsLimit)
return (
<Mjml>
<Head />
<MjmlBody width={600}>
<MjmlSection padding="0">
<MjmlColumn>
<HeroImage src="https://typebot.s3.fr-par.scw.cloud/public/assets/yourBotIsFlyingEmailBanner.png" />
</MjmlColumn>
</MjmlSection>
<MjmlSection padding="0 24px" cssClass="smooth">
<MjmlColumn>
<Text>Your bots are chatting a lot. That's amazing. 💙</Text>
<Text>
This means you've almost reached your monthly chats limit. You
currently reached 80% of {readableChatsLimit} chats.
</Text>
<Text>This limit will be reset on {readableResetDate}.</Text>
<Text fontWeight={800}>
Your bots won't start the chat if you reach the limit before this
date❗
</Text>
<Text>
If you need more monthly responses, you will need to upgrade your
plan.
</Text>
<MjmlSpacer height="24px" />
<Button link={url}>Upgrade workspace</Button>
</MjmlColumn>
</MjmlSection>
</MjmlBody>
</Mjml>
)
}
export const sendAlmostReachedChatsLimitEmail = ({
to,
...props
}: Pick<SendMailOptions, 'to'> &
ComponentProps<typeof AlmostReachedChatsLimitEmail>) =>
sendEmail({
to,
subject: "You're close to your chats limit",
html: render(<AlmostReachedChatsLimitEmail {...props} />).html,
})

View File

@ -0,0 +1,66 @@
import React, { ComponentProps } from 'react'
import {
Mjml,
MjmlBody,
MjmlSection,
MjmlColumn,
MjmlSpacer,
render,
} from '@faire/mjml-react'
import { Button, Head, HeroImage, Text } from '../components'
import { SendMailOptions } from 'nodemailer'
import { sendEmail } from '../sendEmail'
type AlmostReachedStorageLimitEmailProps = {
storageLimit: number
url: string
}
export const AlmostReachedStorageLimitEmail = ({
storageLimit,
url,
}: AlmostReachedStorageLimitEmailProps) => {
const readableStorageLimit = `${storageLimit} GB`
return (
<Mjml>
<Head />
<MjmlBody width={600}>
<MjmlSection padding="0">
<MjmlColumn>
<HeroImage src="https://typebot.s3.fr-par.scw.cloud/public/assets/yourBotIsFlyingEmailBanner.png" />
</MjmlColumn>
</MjmlSection>
<MjmlSection padding="0 24px" cssClass="smooth">
<MjmlColumn>
<Text>Your bots are working a lot. That's amazing. 🤖</Text>
<Text>
This means you've almost reached your storage limit. You currently
reached 80% of your {readableStorageLimit} storage limit.
</Text>
<Text fontWeight={800}>
Your bots won't collect new files once you reach the limit❗
</Text>
<Text>
To make sure it won't happen, you need to upgrade your plan or
delete existing results to free up space.
</Text>
<MjmlSpacer height="24px" />
<Button link={url}>Upgrade workspace</Button>
</MjmlColumn>
</MjmlSection>
</MjmlBody>
</Mjml>
)
}
export const sendAlmostReachedStorageLimitEmail = ({
to,
...props
}: Pick<SendMailOptions, 'to'> &
ComponentProps<typeof AlmostReachedStorageLimitEmail>) =>
sendEmail({
to,
subject: "You're close to your storage limit",
html: render(<AlmostReachedStorageLimitEmail {...props} />).html,
})

View File

@ -0,0 +1,49 @@
import React from 'react'
import { Head, Text, Button } from '../components'
import {
Mjml,
MjmlBody,
MjmlSection,
MjmlColumn,
MjmlSpacer,
} from '@faire/mjml-react'
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,}))$/
type DefaultBotNotificationEmailProps = {
resultsUrl: string
answers: { [key: string]: string }
}
export const DefaultBotNotificationEmail = ({
resultsUrl,
answers,
}: DefaultBotNotificationEmailProps) => (
<Mjml>
<Head />
<MjmlBody width={600}>
<MjmlSection padding="32px" cssClass="smooth" border="1px solid #e2e8f0">
<MjmlColumn>
<Text padding="0">Your typebot has collected a new response! 🥳</Text>
{Object.keys(answers).map((key) => {
const isEmail = emailRegex.test(answers[key])
return (
<Text key={key}>
<b>{key}</b>:{' '}
{isEmail ? (
<a href={answers[key]}>{answers[key]}</a>
) : (
answers[key]
)}
</Text>
)
})}
<MjmlSpacer height="24px" />
<Button link={resultsUrl}>Go to results</Button>
</MjmlColumn>
</MjmlSection>
</MjmlBody>
</Mjml>
)

View File

@ -0,0 +1,66 @@
import React, { ComponentProps } from 'react'
import {
Mjml,
MjmlBody,
MjmlSection,
MjmlColumn,
MjmlSpacer,
render,
} from '@faire/mjml-react'
import { HeroImage, Text, Button, Head } from '../components'
import { SendMailOptions } from 'nodemailer'
import { sendEmail } from '../sendEmail'
type GuestInvitationEmailProps = {
workspaceName: string
typebotName: string
url: string
hostEmail: string
guestEmail: string
}
export const GuestInvitationEmail = ({
workspaceName,
typebotName,
url,
hostEmail,
guestEmail,
}: GuestInvitationEmailProps) => (
<Mjml>
<Head />
<MjmlBody width={600}>
<MjmlSection padding="0">
<MjmlColumn>
<HeroImage src="https://typebot.s3.eu-west-3.amazonaws.com/assets/invitation-banner.png" />
</MjmlColumn>
</MjmlSection>
<MjmlSection padding="0 24px" cssClass="smooth">
<MjmlColumn>
<Text>
You have been invited by {hostEmail} to collaborate on his typebot{' '}
<strong>{typebotName}</strong>.
</Text>
<Text>
From now on you will see this typebot in your dashboard under his
workspace "{workspaceName}" 👍
</Text>
<Text>
Make sure to log in as <i>{guestEmail}</i>.
</Text>
<MjmlSpacer height="24px" />
<Button link={url}>Go to typebot</Button>
</MjmlColumn>
</MjmlSection>
</MjmlBody>
</Mjml>
)
export const sendGuestInvitationEmail = ({
to,
...props
}: Pick<SendMailOptions, 'to'> & ComponentProps<typeof GuestInvitationEmail>) =>
sendEmail({
to,
subject: "You've been invited to collaborate 🤝",
html: render(<GuestInvitationEmail {...props} />).html,
})

View File

@ -0,0 +1,75 @@
import React, { ComponentProps } from 'react'
import {
Mjml,
MjmlBody,
MjmlSection,
MjmlColumn,
MjmlSpacer,
render,
} from '@faire/mjml-react'
import { Button, Head, HeroImage, Text } from '../components'
import { parseNumberWithCommas } from 'utils'
import { SendMailOptions } from 'nodemailer'
import { sendEmail } from '../sendEmail'
type ReachedChatsLimitEmailProps = {
chatsLimit: number
url: string
}
const now = new Date()
const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
const readableResetDate = firstDayOfNextMonth
.toDateString()
.split(' ')
.slice(1, 4)
.join(' ')
export const ReachedChatsLimitEmail = ({
chatsLimit,
url,
}: ReachedChatsLimitEmailProps) => {
const readableChatsLimit = parseNumberWithCommas(chatsLimit)
return (
<Mjml>
<Head />
<MjmlBody width={600}>
<MjmlSection padding="0">
<MjmlColumn>
<HeroImage src="https://typebot.s3.fr-par.scw.cloud/public/assets/actionRequiredEmailBanner.png" />
</MjmlColumn>
</MjmlSection>
<MjmlSection padding="0 24px" cssClass="smooth">
<MjmlColumn>
<Text>
It just happened, you've reached your monthly {readableChatsLimit}{' '}
chats limit 😮
</Text>
<Text fontWeight={800}>
It means your bots are closed until {readableResetDate}❗
</Text>
<Text>
If you'd like to continue chatting with your users this month,
then you need to upgrade your plan. 🚀
</Text>
<MjmlSpacer height="24px" />
<Button link={url}>Upgrade workspace</Button>
</MjmlColumn>
</MjmlSection>
</MjmlBody>
</Mjml>
)
}
export const sendReachedChatsLimitEmail = ({
to,
...props
}: Pick<SendMailOptions, 'to'> &
ComponentProps<typeof ReachedChatsLimitEmail>) =>
sendEmail({
to,
subject: "You've reached your chats limit",
html: render(<ReachedChatsLimitEmail {...props} />).html,
})

View File

@ -0,0 +1,66 @@
import React, { ComponentProps } from 'react'
import {
Mjml,
MjmlBody,
MjmlSection,
MjmlColumn,
MjmlSpacer,
render,
} from '@faire/mjml-react'
import { Button, Head, HeroImage, Text } from '../components'
import { SendMailOptions } from 'nodemailer'
import { sendEmail } from '../sendEmail'
type ReachedStorageLimitEmailProps = {
storageLimit: number
url: string
}
export const ReachedStorageLimitEmail = ({
storageLimit,
url,
}: ReachedStorageLimitEmailProps) => {
const readableStorageLimit = `${storageLimit} GB`
return (
<Mjml>
<Head />
<MjmlBody width={600}>
<MjmlSection padding="0">
<MjmlColumn>
<HeroImage src="https://typebot.s3.fr-par.scw.cloud/public/assets/actionRequiredEmailBanner.png" />
</MjmlColumn>
</MjmlSection>
<MjmlSection padding="0 24px" cssClass="smooth">
<MjmlColumn>
<Text>
It just happened, you've reached your {readableStorageLimit}{' '}
storage limit 😮
</Text>
<Text fontWeight={800}>
It means your bots won't collect new files from your users
</Text>
<Text>
If you'd like to continue collecting files, then you need to
upgrade your plan or remove existing results to free up space. 🚀
</Text>
<MjmlSpacer height="24px" />
<Button link={url}>Upgrade workspace</Button>
</MjmlColumn>
</MjmlSection>
</MjmlBody>
</Mjml>
)
}
export const sendReachedStorageLimitEmail = ({
to,
...props
}: Pick<SendMailOptions, 'to'> &
ComponentProps<typeof ReachedStorageLimitEmail>) =>
sendEmail({
to,
subject: "You've reached your storage limit",
html: render(<ReachedStorageLimitEmail {...props} />).html,
})

View File

@ -0,0 +1,64 @@
import React, { ComponentProps } from 'react'
import {
Mjml,
MjmlBody,
MjmlSection,
MjmlColumn,
MjmlSpacer,
render,
} from '@faire/mjml-react'
import { HeroImage, Text, Button, Head } from '../components'
import { SendMailOptions } from 'nodemailer'
import { sendEmail } from '../sendEmail'
type WorkspaceMemberInvitationProps = {
workspaceName: string
url: string
hostEmail: string
guestEmail: string
}
export const WorkspaceMemberInvitation = ({
workspaceName,
url,
hostEmail,
guestEmail,
}: WorkspaceMemberInvitationProps) => (
<Mjml>
<Head />
<MjmlBody width={600}>
<MjmlSection padding="0">
<MjmlColumn>
<HeroImage src="https://typebot.s3.eu-west-3.amazonaws.com/assets/invitation-banner.png" />
</MjmlColumn>
</MjmlSection>
<MjmlSection padding="0 24px" cssClass="smooth">
<MjmlColumn>
<Text>
You have been invited by {hostEmail} to collaborate on his workspace{' '}
<strong>{workspaceName}</strong> as a team member.
</Text>
<Text>
From now on you will see this workspace in your dashboard 👍
</Text>
<Text>
Make sure to log in as <i>{guestEmail}</i>.
</Text>
<MjmlSpacer height="24px" />
<Button link={url}>Go to workspace</Button>
</MjmlColumn>
</MjmlSection>
</MjmlBody>
</Mjml>
)
export const sendWorkspaceMemberInvitationEmail = ({
to,
...props
}: Pick<SendMailOptions, 'to'> &
ComponentProps<typeof WorkspaceMemberInvitation>) =>
sendEmail({
to,
subject: "You've been invited to collaborate 🤝",
html: render(<WorkspaceMemberInvitation {...props} />).html,
})

View File

@ -0,0 +1,7 @@
export * from './AlmostReachedChatsLimitEmail'
export * from './AlmostReachedStorageLimitEmail'
export * from './DefaultBotNotificationEmail'
export * from './GuestInvitationEmail'
export * from './ReachedChatsLimitEmail'
export * from './ReachedStorageLimitEmail'
export * from './WorkspaceMemberInvitationEmail'

2
packages/emails/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './emails'
export { render } from '@faire/mjml-react'

View File

@ -0,0 +1,32 @@
{
"name": "emails",
"version": "1.0.0",
"description": "",
"main": "./index.ts",
"types": "./index.ts",
"scripts": {
"preview": "concurrently \"pnpm run watch\" \"sleep 5 && pnpm run serve\" -n \"watch,serve\" -c \"bgBlue.bold,bgMagenta.bold\"",
"watch": "tsx watch ./preview.tsx --clear-screen=false",
"serve": "http-server dist -a localhost -p 3223 -o"
},
"keywords": [],
"author": "Baptiste Arnaud",
"license": "ISC",
"devDependencies": {
"@faire/mjml-react": "^2.1.4",
"@types/node": "18.7.16",
"@types/nodemailer": "6.4.5",
"@types/react": "^18.0.21",
"concurrently": "^7.4.0",
"http-server": "^14.1.1",
"nodemailer": "^6.8.0",
"react": "^18.2.0",
"serve": "^14.0.1",
"tsx": "^3.9.0",
"utils": "workspace:*"
},
"peerDependencies": {
"@faire/mjml-react": "^2.1.4",
"nodemailer": "^6.7.8"
}
}

View File

@ -0,0 +1,98 @@
import React from 'react'
import { render } from '@faire/mjml-react'
import fs from 'fs'
import path from 'path'
import {
AlmostReachedChatsLimitEmail,
AlmostReachedStorageLimitEmail,
DefaultBotNotificationEmail,
GuestInvitationEmail,
ReachedChatsLimitEmail,
ReachedStorageLimitEmail,
WorkspaceMemberInvitation,
} from './emails'
const createDistFolder = () => {
const dist = path.resolve(__dirname, 'dist')
if (!fs.existsSync(dist)) {
fs.mkdirSync(dist)
}
}
const createHtmlFile = () => {
fs.writeFileSync(
path.resolve(__dirname, 'dist', 'guestInvitation.html'),
render(
<GuestInvitationEmail
workspaceName={'Typebot'}
typebotName={'Lead Generation'}
url={'https://app.typebot.io'}
hostEmail={'baptiste@typebot.io'}
guestEmail={'guest@typebot.io'}
/>
).html
)
fs.writeFileSync(
path.resolve(__dirname, 'dist', 'workspaceMemberInvitation.html'),
render(
<WorkspaceMemberInvitation
workspaceName={'Typebot'}
url={'https://app.typebot.io'}
hostEmail={'baptiste@typebot.io'}
guestEmail={'guest@typebot.io'}
/>
).html
)
fs.writeFileSync(
path.resolve(__dirname, 'dist', 'almostReachedChatsLimit.html'),
render(
<AlmostReachedChatsLimitEmail
url={'https://app.typebot.io'}
chatsLimit={2000}
/>
).html
)
fs.writeFileSync(
path.resolve(__dirname, 'dist', 'almostReachedStorageLimit.html'),
render(
<AlmostReachedStorageLimitEmail
url={'https://app.typebot.io'}
storageLimit={4}
/>
).html
)
fs.writeFileSync(
path.resolve(__dirname, 'dist', 'reachedChatsLimit.html'),
render(
<ReachedChatsLimitEmail
url={'https://app.typebot.io'}
chatsLimit={10000}
/>
).html
)
fs.writeFileSync(
path.resolve(__dirname, 'dist', 'reachedStorageLimit.html'),
render(
<ReachedStorageLimitEmail
url={'https://app.typebot.io'}
storageLimit={8}
/>
).html
)
fs.writeFileSync(
path.resolve(__dirname, 'dist', 'defaultBotNotification.html'),
render(
<DefaultBotNotificationEmail
resultsUrl={'https://app.typebot.io'}
answers={{
'Group #1': 'Answer #1',
Name: 'Baptiste',
Email: 'baptiste.arnaud95@gmail.com',
}}
/>
).html
)
}
createDistFolder()
createHtmlFile()

View File

@ -0,0 +1,20 @@
import { createTransport, SendMailOptions } from 'nodemailer'
import { env } from 'utils'
export const sendEmail = (
props: Pick<SendMailOptions, 'to' | 'html' | 'subject'>
) => {
const transporter = createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
})
return transporter.sendMail({
from: process.env.SMTP_FROM ?? env('SMTP_FROM'),
...props,
})
}

17
packages/emails/theme.ts Normal file
View File

@ -0,0 +1,17 @@
// Colors
export const black = '#000'
export const gold = '#fadf98'
export const grayDark = '#888'
export const grayLight = '#f5f5f5'
export const blue = '#0042da'
// Typography
export const textSm = 14
export const textBase = 16
export const textLg = 24
export const textXl = 30
export const leadingTight = '120%'
export const leadingRelaxed = '160%'
// Borders
export const borderBase = 6

View File

@ -0,0 +1,6 @@
{
"compilerOptions": {
"jsx": "react",
"esModuleInterop": true
}
}