hir-94: derive per-recipient variables + harden substitution#13
Open
jaredzwick wants to merge 1 commit intopypesdev:mainfrom
Open
hir-94: derive per-recipient variables + harden substitution#13jaredzwick wants to merge 1 commit intopypesdev:mainfrom
jaredzwick wants to merge 1 commit intopypesdev:mainfrom
Conversation
The recipient parser produced only `{ email, name }`, and the campaign
UI passed those straight through. So a template like "Hi {{first_name}},"
was being sent literally — no personalization for users typing recipients
into the textarea, even when the parser had a name to work with.
This change closes that gap and replaces a small substitution bug.
- src/lib/recipientParser.ts: parsed recipients now carry a `variables`
map. It always includes `email`, derives `first_name`/`last_name`
from `Name <email>` form, and falls back to a title-cased email
local-part for `first_name` (so `jane.doe@x.com` → `Jane`). New
`deriveVariables()` is exported for CSV importers / API clients to
reuse the same conventions.
- src/lib/templateSubstitution.ts (new): `applyVariables(template,
vars)` — pure, regex-safe replacement. Tolerates whitespace inside
braces, escapes `$&`-style backreferences in values (the previous
inline `String.replace` path would have re-interpreted them), leaves
unknown placeholders untouched (so missing data shows as `{{x}}`
instead of an awkwardly empty sentence), and refuses to read keys
off the prototype chain.
- src/app/api/campaigns/route.ts: per-recipient subject/bodyHtml/
bodyText substitution now goes through `applyVariables` instead of
the previous inline `RegExp` loop.
Tests:
- 11 new specs in tests/int/recipientParser.int.spec.ts cover
`deriveVariables` (one-word / multi-word names, casing preservation,
email fallback variants `name`/`name.surname`/`NAME_SURNAME`/
`name-surname+tag`, no-invented-last-name, whitespace-only names,
multiple-space split safety), plus a parser-level assertion that
parsed recipients now carry the expected variables shape.
- 14 specs in tests/int/templateSubstitution.int.spec.ts cover single
/ multiple / repeated placeholders, whitespace tolerance,
unknown-placeholder passthrough, empty-string substitution,
$-in-value safety, plain text passthrough, null/undefined args,
single-brace non-match, multi-line non-match, dashed/dotted/numeric
keys, prototype-pollution safety.
No schema, migration, queue, or auth changes. The campaigns POST
contract is unchanged (recipients[].variables was already accepted).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This was referenced May 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
{ email, name }. The campaign create UI passed that straight to/api/campaigns. So a template likeHi {{first_name}},was being sent literally — zero personalization for the textarea path, even when the parser hadName <email>to work with.String.replace(regex, value)path interpreted$&,$1, etc. in user-supplied values as backreferences, mangling any value that contained a dollar sign followed by certain characters.Changes
src/lib/recipientParser.ts: parsed recipients now carry avariablesmap. Always includesemail. Derivesfirst_name/last_namefromName <email>form. Falls back to a title-cased email local-part forfirst_namesojane.doe@x.com→Jane. ExportsderiveVariables()so a future CSV importer can reuse the same conventions.src/lib/templateSubstitution.ts(new):applyVariables(template, vars)— pure, regex-safe (uses a callback-form replacer to neutralize$-backreferences), tolerates whitespace inside braces, leaves unknown placeholders untouched (so missing data renders as{{first_name}}rather than producing an awkwardly blank sentence), and refuses to read keys off the prototype chain ({{toString}}does not substitute).src/app/api/campaigns/route.ts: per-recipient subject / bodyHtml / bodyText substitution now goes throughapplyVariablesinstead of the inline regex loop.Tests
tests/int/recipientParser.int.spec.tscoveringderiveVariables: one-word / multi-word names, casing preservation, email fallback variants (name,name.surname,NAME_SURNAME,name-surname+tag), no-invented-last-name, whitespace-only-name fallback, multi-space split safety, plus a parser-level shape assertion.tests/int/templateSubstitution.int.spec.tscovering single / multiple / repeated placeholders, whitespace tolerance, unknown-placeholder passthrough, empty-string substitution,$-in-value safety, plain-text passthrough, null/undefined args, single-brace non-match, multi-line non-match, dashed/dotted/numeric keys, prototype-pollution safety.pnpm test:int: 120/121 (the 1 failure is the pre-existingapi.int.spec.tsPayload-secret config issue onmain, unrelated).pnpm lintclean for all changed files.Regression analysis
recipients[].variableswas already optional in the schema, this PR just starts populating it from the parser.$-style backreferences in values (which the old code would have silently corrupted).Test plan
Alice Smith <alice@example.com>into the campaigns/new textarea, use the default subjectHi {{first_name}}, and confirm Alice receivesHi Alice.bob.jones@example.com(no angle name) and confirm Bob receivesHi Bob(email-prefix fallback).{{unknown_field}}and confirm it's left literal in the queued email rather than erased.$(e.g.Save \$5) and confirm it's preserved verbatim, not interpreted.Cumulative HIR-94 progress
scheduledFor; CSV recipient upload.🤖 Generated with Claude Code