Adds ability to inject a preheader into emails

This commit is contained in:
Chris Anderson 2023-03-15 19:02:52 -05:00
parent c453448985
commit d26a1e086d
8 changed files with 144 additions and 12 deletions

View file

@ -16,11 +16,17 @@ export default class EmailChannel {
async send(template: EmailTemplate, variables: Variables) {
if (!variables.user.email) throw new Error('Unable to send a text message to a user with no email.')
// TODO: Explore caching the Handlebars template
// before passing in variables for better performance
const compiled = template.compile(variables)
const email = {
...compiled,
to: variables.user.email,
html: Wrap(compiled.html, variables), // Add link and open tracking
html: Wrap({
html: compiled.html,
preheader: compiled.preheader,
variables,
}), // Add link and open tracking
}
await this.provider.send(email)
}

View file

@ -56,7 +56,7 @@ export const encodedLinkToParts = async (link: string | URL): Promise<TrackedLin
return parts
}
export const clickWrapHTML = (html: string, params: TrackedLinkParams) => {
export const clickWrapHtml = (html: string, params: TrackedLinkParams) => {
const regex = /href\s*=\s*(['"])(https?:\/\/.+?)\1/gi
let link
@ -77,14 +77,31 @@ export const clickWrapHTML = (html: string, params: TrackedLinkParams) => {
}
export const openWrapHtml = (html: string, params: TrackedLinkParams) => {
const bodyTag = '</body>'
const link = paramsToEncodedLink({ ...params, path: 'o' })
const imageHtml = `<img border="0" width="1" height="1" alt="" src="${link}" />`
const hasBody = html.includes(bodyTag)
if (hasBody) {
html = html.replace(bodyTag, (imageHtml + bodyTag))
return injectInBody(html, imageHtml, 'end')
}
export const preheaderWrapHtml = (html: string, preheader: string) => {
const preheaderHtml = `<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0">${preheader}</span>`
return injectInBody(html, preheaderHtml, 'start')
}
export const injectInBody = (html: string, injection: string, placement: 'start' | 'end') => {
if (placement === 'end') {
const bodyTag = '</body'
html = html.includes(bodyTag)
? html = html.replace(bodyTag, (injection + bodyTag))
: html += injection
} else {
html += imageHtml
const regex = /<body(?:.|\n)*?>/
const match = html.match(regex)
if (match) {
const offset = match.index! + match[0].length
html = html.substring(0, offset) + injection + html.substring(offset)
} else {
html = injection + html
}
}
return html
}

View file

@ -50,6 +50,7 @@ export interface CompiledEmail {
bcc?: string
reply_to?: string
subject: string
preheader?: string
text: string
html: string
}
@ -61,6 +62,7 @@ export class EmailTemplate extends Template {
bcc?: string
reply_to?: string
subject!: string
preheader?: string
text!: string
html!: string
@ -72,6 +74,7 @@ export class EmailTemplate extends Template {
this.bcc = json?.data.bcc
this.reply_to = json?.data.reply_to
this.subject = json?.data.subject ?? ''
this.preheader = json?.data.preheader
this.text = json?.data.text ?? ''
this.html = json?.data.html ?? ''
}
@ -83,6 +86,7 @@ export class EmailTemplate extends Template {
html: Render(this.html, variables),
text: Render(this.text, variables),
}
if (this.preheader) email.preheader = Render(this.preheader, variables)
if (this.reply_to) email.reply_to = Render(this.reply_to, variables)
if (this.cc) email.cc = Render(this.cc, variables)
if (this.bcc) email.bcc = Render(this.bcc, variables)

View file

@ -0,0 +1,76 @@
import { Admin } from '../../auth/Admin'
import { createProject } from '../../projects/ProjectService'
import { createUser } from '../../users/UserRepository'
import { uuid } from '../../utilities'
import { clickWrapHtml, encodedLinkToParts, openWrapHtml, paramsToEncodedLink, preheaderWrapHtml } from '../LinkService'
afterEach(() => {
jest.clearAllMocks()
})
describe('LinkService', () => {
describe('encodedLinkToParts', () => {
test('a properly encoded link decodes to parts', async () => {
const adminId = await Admin.insert({
first_name: uuid(),
last_name: uuid(),
email: `${uuid()}@test.com`,
})
const project = await createProject(adminId, { name: uuid() })
const user = await createUser(project.id, {
anonymous_id: uuid(),
external_id: uuid(),
})
const redirect = 'http://test.com'
const link = paramsToEncodedLink({
userId: user.id,
campaignId: 1,
path: 'c',
redirect,
})
const parts = await encodedLinkToParts(link)
expect(parts.user?.id).toEqual(user.id)
expect(parts.redirect).toEqual(redirect)
})
})
describe('clickWrapHtml', () => {
test('links are wrapped', async () => {
const html = 'This is some html <a href="https://test.com">Test Link</a>'
const output = clickWrapHtml(html, { userId: 1, campaignId: 2 })
expect(output).toMatchSnapshot()
})
})
describe('openWrapHtml', () => {
test('open tracking image is added to end of body', async () => {
const html = '<html><body>This is some html</body></html>'
const output = openWrapHtml(html, { userId: 3, campaignId: 4 })
expect(output).toMatchSnapshot()
})
})
describe('preheaderWrapHtml', () => {
test('simple html injects preheader', async () => {
const preheader = 'This is some preheader'
const html = '<html><body>This is some html</body></html>'
const output = preheaderWrapHtml(html, preheader)
expect(output).toMatchSnapshot()
})
test('complex html injects preheader', async () => {
const preheader = 'This is some preheader'
const html = `<html>
<body
style="color:#000"
class="body-class">
This is some html
</body>
</html>`
const output = preheaderWrapHtml(html, preheader)
expect(output).toMatchSnapshot()
})
})
})

View file

@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LinkService clickWrapHtml links are wrapped 1`] = `"This is some html <a href=\\"https://twochris.com/c?u=M8LRMWZ645&c=1MLXN7R524&r=https%253A%252F%252Ftest.com\\">Test Link</a>"`;
exports[`LinkService openWrapHtml open tracking image is added to end of body 1`] = `"<html><body>This is some html<img border=\\"0\\" width=\\"1\\" height=\\"1\\" alt=\\"\\" src=\\"https://twochris.com/o?u=P9KR98X8L4&c=Y3QRV4XL74\\" /></body></html>"`;
exports[`LinkService preheaderWrapHtml complex html injects preheader 1`] = `
"<html>
<body
style=\\"color:#000\\"
class=\\"body-class\\"><span style=\\"color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0\\">This is some preheader</span>
This is some html
</body>
</html>"
`;
exports[`LinkService preheaderWrapHtml simple html injects preheader 1`] = `"<html><body><span style=\\"color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0\\">This is some preheader</span>This is some html</body></html>"`;

View file

@ -6,7 +6,7 @@ import * as UrlHelpers from './Helpers/Url'
import * as ArrayHelpers from './Helpers/Array'
import { User } from '../users/User'
import { unsubscribeEmailLink } from '../subscriptions/SubscriptionService'
import { clickWrapHTML, openWrapHtml } from './LinkService'
import { clickWrapHtml, openWrapHtml, preheaderWrapHtml } from './LinkService'
export interface RenderContext {
template_id: number
@ -42,10 +42,16 @@ export const Compile = (template: string, context: Record<string, any> = {}) =>
return Handlebars.compile(template)(context)
}
export const Wrap = (html: string, { user, context }: Variables) => {
interface WrapParams {
html: string
preheader?: string
variables: Variables
}
export const Wrap = ({ html, preheader, variables: { user, context } }: WrapParams) => {
const trackingParams = { userId: user.id, campaignId: context.campaign_id }
html = clickWrapHTML(html, trackingParams)
html = clickWrapHtml(html, trackingParams)
html = openWrapHtml(html, trackingParams)
if (preheader) html = preheaderWrapHtml(html, preheader)
return html
}

View file

@ -283,6 +283,7 @@ export interface EmailTemplateData {
bcc?: string
reply_to?: string
subject: string
preheader?: string
text: string
html: string
}

View file

@ -18,6 +18,7 @@ const EmailTable = ({ data }: { data: EmailTemplateData }) => <InfoTable rows={{
CC: data.cc,
BCC: data.bcc,
Subject: data.subject,
Preheader: data.preheader,
}} />
const EmailForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => <>
@ -27,10 +28,15 @@ const EmailForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> })
label="Subject"
textarea
required />
<TextField
form={form}
name="data.preheader"
label="Preheader"
textarea />
<TextField form={form} name="data.from" label="From Email" required />
<TextField form={form} name="data.reply_to" label="Reply To" />
<TextField form={form} name="data.cc" label="CC" />
<TextField form={form} name="data.cc" label="BCC" />
<TextField form={form} name="data.bcc" label="BCC" />
</>
const TextTable = ({ data: { text } }: { data: TextTemplateData }) => {
@ -96,7 +102,6 @@ export default function TemplateDetail({ template }: TemplateDetailProps) {
const newCampaign = { ...campaign }
newCampaign.templates = campaign.templates.map(obj => obj.id === id ? value : obj)
setCampaign(newCampaign)
console.log('new campaign', newCampaign)
setIsEditOpen(false)
}