Skip to content

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

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

hir-94: derive per-recipient variables + harden substitution#13
jaredzwick wants to merge 1 commit intopypesdev: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>
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