diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts index 16f08e8..84f8354 100644 --- a/src/app/api/campaigns/route.ts +++ b/src/app/api/campaigns/route.ts @@ -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, @@ -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(); diff --git a/src/lib/recipientParser.ts b/src/lib/recipientParser.ts index 4fcda67..96de4b1 100644 --- a/src/lib/recipientParser.ts +++ b/src/lib/recipientParser.ts @@ -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 } export type RecipientParseResult = { @@ -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 { + const vars: Record = { 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 = '' diff --git a/src/lib/templateSubstitution.ts b/src/lib/templateSubstitution.ts new file mode 100644 index 0000000..f495bbb --- /dev/null +++ b/src/lib/templateSubstitution.ts @@ -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 | 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 + }) +} diff --git a/tests/int/recipientParser.int.spec.ts b/tests/int/recipientParser.int.spec.ts index 876ba95..5cda687 100644 --- a/tests/int/recipientParser.int.spec.ts +++ b/tests/int/recipientParser.int.spec.ts @@ -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 angle format', () => { const r = parseRecipients('Alice Smith ') - 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." ') - 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', () => { @@ -59,7 +57,88 @@ describe('parseRecipients', () => { it('omits the name key entirely when angle name is empty', () => { const r = parseRecipients(' ') - 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 \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') + }) }) diff --git a/tests/int/templateSubstitution.int.spec.ts b/tests/int/templateSubstitution.int.spec.ts new file mode 100644 index 0000000..242e943 --- /dev/null +++ b/tests/int/templateSubstitution.int.spec.ts @@ -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}}') + }) +})