loading...

13/08/2026

Why your emails show – instead of a dash, and the fallback trap that keeps the bug alive after you fix it

Why your emails show – instead of a dash, and the fallback trap that keeps the bug alive after you fix it

I’m building Rivale.io, a competitive-intelligence tool for WordPress plugin founders and developers, and one morning my own weekly digest email greeted me with a plugin name rendered as raw code: “Jetpack – WP Security…”. The WordPress.org API sends plugin names HTML-entity-encoded and one field in my notification pipeline was reaching the screen without ever being decoded.

The first fix looked textbook: decode in the one shared function that formats names, covering about 35 call sites in a single edit. Every test passed. Two review passes found nothing. Then I asked one question:

Are all other notification channels affected too?

That surfaced the finding this guide is all about: three code paths used the fixed function only as a fallback, so wherever a stored title existed, the fix never executed. There was no error and no failing test. The repair was just silently skipped.

I ran the investigation from there, choosing each next check and settling every open question against production data, including the moment two audit agents contradicted each other and my live query proved both partly wrong. Claude wrote and ran the code.

This guide walks you through the whole arc: the encoding trap, the fix, the bypass class that survives a green test suite, and the checks that would have caught each one in minutes.

The symptom

A product email arrives with the plugin name rendered like this:

Jetpack – WP Security, Backup, Speed, & Growth

Two separate things are wrong in that one line:

  1. – is an HTML entity (an en dash) showing as literal text.
  2. & is a double-encoded ampersand. The name was stored as &, then an escaping step encoded the & again.
The digest headline rendering the encoded plugin name as literal text
The actual weekly digest with the issue

If you build anything on top of the WordPress.org plugin API, you will hit this. That API hands back display-ready HTML, not plain text. The author field is a full <a href> anchor tag, and names arrive entity-encoded (&#8211;, &amp;, &#8217;).

What makes it easy to miss is that the encoding is not consistent. Across the 100 most popular plugins, 38 names come back entity-encoded and 8 carry the literal character instead, because WP.org passes through whatever each author typed into their readme header.

Architecture at a glance

Pipeline: WordPress.org API names arrive entity-encoded, stored raw, decoded by a shared helper - except one persisted-title path that bypasses it

The most commonly misconfigured component is the dotted path: a writer persisted raw names into a table whose title column is read back in preference to the helper’s decoded output. That path is why fixing the helper was not enough.

Step 1: Find where the encoding enters, and resist the urge to fix it there

The names were stored encoded, exactly as the WP.org API delivered them. First instinct: decode before storing. Wrong, for two reasons that came out of this session:

  • Many independent readers already decode at display time. Changing what’s stored changes the contract for all of them.
  • Historical rows in immutable logs can’t be backfilled anyway. Decoding at write time only helps future rows.

So the rule we operated under: the database holds the raw value, decoding is a presentation concern. (Step 4 covers the one exception that bit us.)

Step 2: Fix the shared helper, and catch the silent string-op failures

One shared helper shortens plugin names for display, and roughly 35 call sites use it. Adding the decode there looks like total coverage in one edit:

// simplified: the pattern, not the literal file
function shortName(raw: string): string {
  const name = decodeHtmlEntities(raw);   // decode FIRST
  return name.split(/\s+[–—-]\s+/)[0];    // then split/truncate
}

Order matters more than it looks:

Warning: String operations on encoded text fail silently. Our truncation split on a literal , which never matches &#8211;, so truncation had never worked for encoded names. The full SEO tail survived into headlines and email subjects. Separately, a 70-character subject cut was spending 7 of its characters on markup and could slice an entity in half. No error is thrown in either case. The output is just quietly wrong.

Also check the escape order at render sites. Escaping before decoding is what produces the double-encoded &amp;#8211;:

stored:   Jetpack &#8211; WP Security
escape:   Jetpack &amp;#8211; WP Security   ← what the email showed
decode→escape: Jetpack – WP Security        ← correct

At this point the fix passed every check: full test suite green, both type-checkers clean, lint clean, two reviewer passes with no defects. It looked done.

Step 3: Ask “is anything else affected?”

The fix so far covered one email. The question “are all other notification channels affected by this issue as well?” triggered a sweep of every notification producer in the codebase, and that sweep found the finding this guide exists for.

Step 4: The || fallback bypass

Three separate sites had this shape:

// simplified
const headline = record.title || fallbackTemplate(shortName(competitor.name));

shortName is fixed. But it’s the right-hand operand of a ||. Wherever record.title is non-empty, the fixed code never executes. And that title was being written by another function from the same raw, encoded name, so the bypass did more than skip the fix. It delivered the exact bug the fix was for, through a path the fix never touched.

Important: Call-site coverage is not execution-path coverage. A source-level fix in a shared helper covers every place the helper is called, not every path a value takes to the screen. After fixing any shared helper, grep for expressions where your helper is the fallback operand:

grep -rn "|| .*shortName(" src/ functions/
grep -rn "|| .*decodeHtmlEntities(" src/ functions/

Every hit is a site where your fix silently doesn’t run. This takes seconds and it found three bypasses here.

The sweep also surfaced the exception to Step 1’s rule:

Persisted text that becomes someone else’s input is not a display site. The writer storing raw names into that title column had to decode at write time, because three readers treat that column as primary text and prefer it over any decoded fallback. “Decode only at display” holds for a name in its own column with many independent readers. It breaks the moment a derived string is stored and read back as authoritative.

One more variant from the same sweep, visible only if you render it: a UI component showed a decoded plugin name on one line and the raw encoded title on the next, from the same row.

Step 5: Settle disagreements with live data, not with either claim

Two sweep agents (Claude subagents, each auditing the notification producers independently) contradicted each other on whether the bypassed path ever actually carries a plugin name. One said yes, one traced the writer and said never.

A query against production settled it, using an entity-detection regex worth keeping:

SELECT count(*) AS encoded_rows, min(left(text_col, 90)) AS sample
FROM your_table   -- any table holding user-facing text
WHERE text_col ~ '&(#[0-9]{2,5}|amp|lt|gt|quot|apos);';

Both agents were partly wrong. The rows existed (one agent right about the risk), but most were legacy rows from retired code, and the live-path subset was small but real: encoded titles from exactly the bypassed read path. When two automated audits disagree, neither claim is the tiebreaker. The live data is what settled it here.

Live damage count, same regex: about a third of competitor names and a fifth of stored headlines were encoded. This had been minting bad rows for months, including the week of the fix.

Step 6: Decide the backfill question with the read window, not with instinct

No backfill shipped, and that was the deliberate call, not an omission:

  1. The email digest only reads recent rows (an 8-day window). Old encoded rows never re-enter an email on their own.
  2. The historical notification log is immutable by design. A backfill couldn’t clean it anyway.

So for everything already stored, the display-time decode is the only fix that applies. Source decode stops new bad rows, display decode renders the old ones correctly, and the stored data stays untouched.

Step 7: Verify the deploy, and state what you can’t verify yet

Post-deploy checks confirmed no function failures and no new encoded rows. But every logged run predated the redeploy, so the honest status at end of session was suggestive rather than proof. The new code simply hadn’t executed on a schedule yet. Real confirmation belonged to the next day’s scheduled runs.

Worth stating plainly in your own write-ups: “deploy succeeded” and “the fix has executed in production” are different claims, and the second one has a timestamp you can check.

Verification checklist

CheckActionExpected
Encoded rows in any text columnRun the entity regex from Step 5 against each tableKnown-legacy counts only, no growth
New rows post-deploySame regex filtered to rows created after the deploy timestampZero encoded
Fallback bypassesgrep -rn "|| .*yourHelper(" across the codebaseZero hits, or each hit decodes its primary
Double-encodingSearch rendered output for &amp;#Absent
Truncation actually truncatesFeed an encoded name through the helper in a testShort name, not the SEO tail
Empty-input behaviordecode('') still hits your || 'default' fallbacksDefaults preserved

Troubleshooting reference

SymptomRoot causeFix
&#8211; or &#8217; as literal text in email/UIValue stored entity-encoded (WP.org API), rendered without decodeDecode at every display site
&amp;#8211; (double-encoded)Escape ran before decodeDecode first, then escape
Name truncation/splitting silently not appliedSplit pattern is a literal character that never matches its encoded formDecode before any split, slice, or length check
Fix verified, bug still appears on some surfacesFixed helper sits as the right operand of a || fallbackGrep for || yourHelper(, decode the primary operand
Old rows still render encoded after the source fixStored data unchanged (by design)Display-time decode, not a backfill, when read windows and immutable logs make backfill useless

Conclusion

Decoding now happens at the source helper, at every display surface, and at the one write path whose output is read back as primary text.

What I’d carry to any codebase: after fixing a shared helper, grep for the || helper( bypasses, because call-site coverage says nothing about execution paths. And when two audits disagree, the live data is the tiebreaker.


Raw draft by Claude from session transcripts. Edited, fact-checked, and finalized by Matteo Duò.

Share this post on LinkedIn

Posted in Building in public, How To, WordPress