Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 6 additions & 13 deletions src/app/api/campaigns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { nanoid } from 'nanoid';
import { requireAuth, AuthorizationError } from '@/lib/authorization';
import { applyVariables } from '@/lib/templateSubstitution';
import {
createCampaign,
bulkCreateQueueEntries,
Expand Down Expand Up @@ -82,19 +83,11 @@ export async function POST(request: NextRequest) {

// Create queue entries for all recipients
const queueEntries = validatedData.recipients.map((recipient) => {
// Simple variable replacement for subject and body
let subject = validatedData.subject;
let bodyHtml = validatedData.bodyHtml || '';
let bodyText = validatedData.bodyText;

if (recipient.variables) {
Object.entries(recipient.variables).forEach(([key, value]) => {
const placeholder = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
subject = subject.replace(placeholder, value);
bodyHtml = bodyHtml.replace(placeholder, value);
bodyText = bodyText.replace(placeholder, value);
});
}
const subject = applyVariables(validatedData.subject, recipient.variables);
const bodyHtml = validatedData.bodyHtml
? applyVariables(validatedData.bodyHtml, recipient.variables)
: '';
const bodyText = applyVariables(validatedData.bodyText, recipient.variables);

// Generate unique tracking ID
const trackingId = nanoid();
Expand Down
50 changes: 49 additions & 1 deletion src/lib/recipientParser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
export type ParsedRecipient = {
email: string
name?: string
/**
* Default per-recipient template variables. Always includes `email`. Adds
* `first_name` / `last_name` when an angle-format name was supplied; falls
* back to a title-cased email local-part for `first_name` so that templates
* like "Hi {{first_name}}," never render with literal braces.
*
* The campaign API also accepts caller-supplied variables (e.g. from a CSV
* import) and these defaults can be overridden per-recipient.
*/
variables: Record<string, string>
}

export type RecipientParseResult = {
Expand Down Expand Up @@ -44,12 +54,50 @@ export function parseRecipients(input: string): RecipientParseResult {
if (seen.has(key)) continue
seen.add(key)

recipients.push(name ? { email, name } : { email })
const variables = deriveVariables({ email, name })
recipients.push(name ? { email, name, variables } : { email, variables })
}

return { recipients, invalid }
}

/**
* Derive default template variables for a recipient. Pure function — exposed
* so callers (CSV importers, API clients) can use the same conventions.
*/
export function deriveVariables({
email,
name,
}: {
email: string
name?: string
}): Record<string, string> {
const vars: Record<string, string> = { email }

const nameParts = (name ?? '').split(/\s+/).filter((p) => p.length > 0)
if (nameParts.length > 0) {
vars.first_name = nameParts[0]
if (nameParts.length > 1) {
vars.last_name = nameParts.slice(1).join(' ')
}
return vars
}

// Fall back to a title-cased email local-part so a bare email like
// jane.doe@example.com still produces a usable {{first_name}}.
const localPart = email.split('@')[0] ?? ''
const firstChunk = localPart
.split(/[._\-+]/)
.map((s) => s.trim())
.filter((s) => s.length > 0)[0]
if (firstChunk) {
vars.first_name =
firstChunk.charAt(0).toUpperCase() + firstChunk.slice(1).toLowerCase()
}

return vars
}

function splitSegments(input: string): string[] {
const segments: string[] = []
let buf = ''
Expand Down
29 changes: 29 additions & 0 deletions src/lib/templateSubstitution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Template variable substitution.
*
* Replaces `{{name}}` placeholders in a string with values from a variables
* map. Pure: no I/O, no globals, no side effects on the input map.
*
* Behavior:
* - Whitespace inside the braces is tolerated: `{{ first_name }}`.
* - Unknown placeholders are left as-is so missing data doesn't silently
* produce blank emails (the receiver sees `{{first_name}}`, not nothing).
* - Empty-string variable values DO substitute (this is a deliberate
* opt-in: callers can pass `''` to suppress a placeholder explicitly).
* - The brace syntax is regex-safe: variables containing `$` are not
* interpreted as backreferences during replacement.
*/
export function applyVariables(
template: string,
variables: Record<string, string> | undefined | null
): string {
if (!template) return template
if (!variables) return template

return template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (match, key: string) => {
if (Object.prototype.hasOwnProperty.call(variables, key)) {
return variables[key]
}
return match
})
}
103 changes: 91 additions & 12 deletions tests/int/recipientParser.int.spec.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
import { describe, expect, it } from 'vitest'
import { parseRecipients } from '@/lib/recipientParser'
import { deriveVariables, parseRecipients } from '@/lib/recipientParser'

describe('parseRecipients', () => {
it('parses bare emails one per line', () => {
const r = parseRecipients('alice@example.com\nbob@example.com')
expect(r.recipients).toEqual([
{ email: 'alice@example.com' },
{ email: 'bob@example.com' },
expect(r.recipients.map((p) => p.email)).toEqual([
'alice@example.com',
'bob@example.com',
])
expect(r.invalid).toEqual([])
})

it('parses Name <email> angle format', () => {
const r = parseRecipients('Alice Smith <alice@example.com>')
expect(r.recipients).toEqual([
{ email: 'alice@example.com', name: 'Alice Smith' },
])
expect(r.recipients).toHaveLength(1)
expect(r.recipients[0].email).toBe('alice@example.com')
expect(r.recipients[0].name).toBe('Alice Smith')
})

it('strips quotes around angle-format names', () => {
const r = parseRecipients('"Alice, Q." <alice@example.com>')
expect(r.recipients[0]).toEqual({
email: 'alice@example.com',
name: 'Alice, Q.',
})
expect(r.recipients[0].email).toBe('alice@example.com')
expect(r.recipients[0].name).toBe('Alice, Q.')
})

it('accepts comma and semicolon separators in addition to newlines', () => {
Expand Down Expand Up @@ -59,7 +57,88 @@ describe('parseRecipients', () => {

it('omits the name key entirely when angle name is empty', () => {
const r = parseRecipients(' <alice@example.com>')
expect(r.recipients[0]).toEqual({ email: 'alice@example.com' })
expect(r.recipients[0].email).toBe('alice@example.com')
expect('name' in r.recipients[0]).toBe(false)
})

it('emits per-recipient default variables', () => {
const r = parseRecipients('Alice Smith <alice@example.com>\nbob@example.com')
expect(r.recipients[0].variables).toEqual({
email: 'alice@example.com',
first_name: 'Alice',
last_name: 'Smith',
})
expect(r.recipients[1].variables).toEqual({
email: 'bob@example.com',
first_name: 'Bob',
})
})
})

describe('deriveVariables', () => {
it('always includes the email address', () => {
expect(deriveVariables({ email: 'a@b.com' }).email).toBe('a@b.com')
})

it('splits a one-word name into first_name only', () => {
const v = deriveVariables({ email: 'a@b.com', name: 'Alice' })
expect(v.first_name).toBe('Alice')
expect('last_name' in v).toBe(false)
})

it('splits a multi-word name into first_name and last_name', () => {
const v = deriveVariables({ email: 'a@b.com', name: 'Alice Smith' })
expect(v.first_name).toBe('Alice')
expect(v.last_name).toBe('Smith')
})

it('keeps multi-word last names intact', () => {
const v = deriveVariables({ email: 'a@b.com', name: 'Mary Jane Watson' })
expect(v.first_name).toBe('Mary')
expect(v.last_name).toBe('Jane Watson')
})

it('preserves casing in the source name verbatim', () => {
const v = deriveVariables({ email: 'a@b.com', name: 'al SMITH' })
expect(v.first_name).toBe('al')
expect(v.last_name).toBe('SMITH')
})

it('falls back to a title-cased email local-part when no name is given', () => {
expect(deriveVariables({ email: 'jane@example.com' }).first_name).toBe(
'Jane'
)
expect(deriveVariables({ email: 'jane.doe@example.com' }).first_name).toBe(
'Jane'
)
expect(deriveVariables({ email: 'JANE_DOE@example.com' }).first_name).toBe(
'Jane'
)
expect(
deriveVariables({ email: 'jane-doe+promo@example.com' }).first_name
).toBe('Jane')
})

it('does not invent a last_name from the email local-part', () => {
expect('last_name' in deriveVariables({ email: 'jane.doe@example.com' })).toBe(
false
)
})

it('treats whitespace-only names as no name', () => {
const v = deriveVariables({ email: 'jane@example.com', name: ' ' })
expect(v.first_name).toBe('Jane')
expect('last_name' in v).toBe(false)
})

it('ignores empty name parts so multiple spaces dont break first_name', () => {
const v = deriveVariables({ email: 'a@b.com', name: ' Alice Smith ' })
expect(v.first_name).toBe('Alice')
expect(v.last_name).toBe('Smith')
})

it('handles an email with no local-part separator gracefully', () => {
const v = deriveVariables({ email: 'jane@example.com' })
expect(v.first_name).toBe('Jane')
})
})
98 changes: 98 additions & 0 deletions tests/int/templateSubstitution.int.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { applyVariables } from '@/lib/templateSubstitution'

describe('applyVariables', () => {
it('replaces a single placeholder', () => {
expect(applyVariables('Hi {{first_name}}', { first_name: 'Alice' })).toBe(
'Hi Alice'
)
})

it('replaces multiple distinct placeholders in one pass', () => {
expect(
applyVariables('Hi {{first_name}} from {{company}}', {
first_name: 'Alice',
company: 'Acme',
})
).toBe('Hi Alice from Acme')
})

it('replaces every occurrence of the same placeholder', () => {
expect(
applyVariables('{{first_name}} — yes, {{first_name}}', {
first_name: 'Alice',
})
).toBe('Alice — yes, Alice')
})

it('tolerates whitespace inside the braces', () => {
expect(applyVariables('Hi {{ first_name }}', { first_name: 'Alice' })).toBe(
'Hi Alice'
)
expect(
applyVariables('Hi {{ first_name\t}}', { first_name: 'Alice' })
).toBe('Hi Alice')
})

it('leaves unknown placeholders untouched (safer than blank)', () => {
expect(applyVariables('Hi {{first_name}} at {{company}}', { first_name: 'A' })).toBe(
'Hi A at {{company}}'
)
})

it('substitutes empty-string values explicitly (no fallback)', () => {
expect(applyVariables('Hi {{first_name}}!', { first_name: '' })).toBe(
'Hi !'
)
})

it('does not interpret $ in replacement values as backreferences', () => {
// The naive `.replace(regex, value)` path would treat `$&` etc. as
// backreferences. Confirm we are robust to user-supplied dollar signs.
expect(applyVariables('cost {{price}}', { price: '$5 + $&' })).toBe(
'cost $5 + $&'
)
expect(applyVariables('{{x}} {{x}}', { x: '$1' })).toBe('$1 $1')
})

it('returns the input unchanged when there are no placeholders', () => {
expect(applyVariables('plain text', { foo: 'bar' })).toBe('plain text')
expect(applyVariables('plain text', {})).toBe('plain text')
})

it('returns the input unchanged when variables is null/undefined', () => {
expect(applyVariables('Hi {{first_name}}', undefined)).toBe('Hi {{first_name}}')
expect(applyVariables('Hi {{first_name}}', null)).toBe('Hi {{first_name}}')
})

it('returns the input unchanged for empty/null template', () => {
expect(applyVariables('', { first_name: 'A' })).toBe('')
})

it('does not match single-brace placeholders', () => {
expect(applyVariables('Hi {first_name}', { first_name: 'A' })).toBe(
'Hi {first_name}'
)
})

it('does not match malformed multi-line braces', () => {
expect(
applyVariables('Hi {{first_\nname}}', { 'first_\nname': 'X' })
).toBe('Hi {{first_\nname}}')
})

it('matches keys with dashes, dots, and digits', () => {
expect(
applyVariables('a {{first-name}} b {{co.name}} c {{x1}}', {
'first-name': 'Al',
'co.name': 'Acme',
x1: '42',
})
).toBe('a Al b Acme c 42')
})

it('does not invent values from the prototype chain', () => {
expect(applyVariables('{{toString}}', {})).toBe('{{toString}}')
expect(applyVariables('{{constructor}}', {})).toBe('{{constructor}}')
})
})