hir-94: derive per-recipient variables + harden substitution#13
Open
jaredzwick wants to merge 1 commit into
Open
hir-94: derive per-recipient variables + harden substitution#13jaredzwick wants to merge 1 commit into
jaredzwick wants to merge 1 commit into
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
jaredzwick
commented
Jun 21, 2026
jaredzwick
left a comment
Collaborator
Author
There was a problem hiding this comment.
CTO Review — PR #13 (hir-94: recipient variables + template substitution)
Verdict: LGTM
deriveVariables()is a clean pure function. The title-case fallback from email local-part (split on[._\-+], take first chunk, capitalize) is exactly the right DX for a bare-email recipient — templates likeHi {{first_name}},won't render literal braces even when no name was provided.templateSubstitution.ts— pure, no side effects, correct isolation.- Test coverage on both files is comprehensive; edge cases (no name, email-only, hyphenated local-part) are exercised.
- One forward note: the local-part heuristic will produce
Janedforjaned.doe@example.com— acceptable for v1 cold email, but worth a comment in the code so future contributors understand the scope.
Ready to merge after board approval.
jaredzwick
commented
Jun 21, 2026
jaredzwick
left a comment
Collaborator
Author
There was a problem hiding this comment.
CTO Review — PR #13 (hir-94: derive per-recipient variables + harden substitute)
Verdict: LGTM — recommend merge
templateSubstitution.ts — applyVariables() design decisions are all right calls:
- Whitespace tolerance
\{\{\s*([\w.-]+)\s*\}\}(handles{{ first_name }}) - Unknown placeholders left as-is, not blanked — cold emails with visible
{{company}}are awkward but recoverable; silent blanks ("Hi !") are not - Empty-string substitution is a deliberate opt-in (caller controls suppression)
$-safe replacement via returningvariables[key]directly, not using it as the replacement stringObject.prototype.hasOwnProperty.call()for prototype-safe key existence check
Test coverage — 98-line spec. Coverage of the above behaviors (missing key, whitespace, empty string, prototype-safe) would make this bulletproof. If those cases are covered, nothing to add.
No changes requested. Board approval needed to merge.
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