Skip to content

hir-94: derive per-recipient variables + harden substitution#13

Open
jaredzwick wants to merge 1 commit into
pypesdev:mainfrom
jaredzwick:hir-94/recipient-variables
Open

hir-94: derive per-recipient variables + harden substitution#13
jaredzwick wants to merge 1 commit into
pypesdev:mainfrom
jaredzwick:hir-94/recipient-variables

Conversation

@jaredzwick

Copy link
Copy Markdown
Collaborator

Summary

  • The recipient parser produced only { email, name }. The campaign create UI passed that straight to /api/campaigns. So a template like Hi {{first_name}}, was being sent literally — zero personalization for the textarea path, even when the parser had Name <email> to work with.
  • Fixed the gap and replaced a small but real bug in the substitution logic: the previous inline 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 a variables map. Always includes email. Derives first_name / last_name from Name <email> form. Falls back to a title-cased email local-part for first_name so jane.doe@x.comJane. Exports deriveVariables() 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 through applyVariables instead of the inline regex loop.

Tests

  • 11 new specs in tests/int/recipientParser.int.spec.ts covering 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-name fallback, multi-space split safety, plus a parser-level shape assertion.
  • 14 specs in tests/int/templateSubstitution.int.spec.ts covering 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.
  • All 34 directly-affected specs pass. Full pnpm test:int: 120/121 (the 1 failure is the pre-existing api.int.spec.ts Payload-secret config issue on main, unrelated).
  • pnpm lint clean for all changed files.

Regression analysis

  • The campaigns POST contract is unchanged — recipients[].variables was already optional in the schema, this PR just starts populating it from the parser.
  • The substitution refactor is behavior-preserving for every input the previous loop handled correctly, and strictly safer for inputs containing $-style backreferences in values (which the old code would have silently corrupted).
  • No schema, migration, queue, auth, or send-pipeline changes.

Test plan

  • Type Alice Smith <alice@example.com> into the campaigns/new textarea, use the default subject Hi {{first_name}}, and confirm Alice receives Hi Alice.
  • Type bob.jones@example.com (no angle name) and confirm Bob receives Hi Bob (email-prefix fallback).
  • Use a body with an unknown variable like {{unknown_field}} and confirm it's left literal in the queued email rather than erased.
  • Use a value containing $ (e.g. Save \$5) and confirm it's preserved verbatim, not interpreted.

Cumulative HIR-94 progress

🤖 Generated with Claude Code

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>

@jaredzwick jaredzwick left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 like Hi {{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 Janed for janed.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 jaredzwick left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CTO Review — PR #13 (hir-94: derive per-recipient variables + harden substitute)

Verdict: LGTM — recommend merge


templateSubstitution.tsapplyVariables() 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 returning variables[key] directly, not using it as the replacement string
  • Object.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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant