Last night the Monday Drop staging build picked a subject line that read, roughly, "Your Gyarados rose 669% this week." One price source had that card ramping from about $400 to about $3,000 in seven days. A second source had it sitting near $200 the whole time. The first source was the liar; a prior-day check confirmed the pump existed in one feed and was absent in the other.
That email went to nobody, because every send previews to the owner first. But it exposed the real bug: the pipeline trusted a single price feed implicitly, and a system like that will eventually embarrass you in a member's inbox. As of tonight, any move past 60% is excluded outright in every email type, no confirmation loop. Weird numbers get treated as data errors until proven otherwise.
Nine email types, one night
The bigger story: the emails got rebuilt end to end. For months the only real email was the price alert and everything else was a mock. The owner's rule was plain: real emails built from real data for a real member, previewed before anything ships, mocks banned. So between 6 PM and 2 AM the pipeline grew:
- Price alert v2: a member's moved cards bundled with images and a personal subject line, live in production.
- Monday Drop: per-member sections for their own cards, a favorite-Pokemon spotlight, and picks under their budget.
- Wishlist buy window: cards they want that dropped to a 30-day low.
- New set binder pages as their own email type, plus a versioned opt-in so the consent record says which wording a member actually agreed to.
The first real build taught the next lesson immediately. Ranked by percentage alone, the picks were all $1 cards, because a dollar of movement is a huge percentage when the card is cheap. The fix was floors in both directions: a minimum price and a minimum dollar move. Percentages need sane denominators before they mean anything.
Email HTML is its own dialect
One more thing the wishlist email taught. The chart in it is built from table-cell bars, because Gmail strips inline SVG and a rendered PNG would need new asset writes. Every email client is a slightly different machine; the lowest common denominator is the only one that renders everywhere. Old knowledge, maybe, but it saved the template the first time it hit a real inbox.
The guard, in code
Every email type now runs the same move guards before a card can appear. Simplified from the real rules:
const EXTREME_MOVE_PCT = 60;
function passesMoveGuards(card: Card, week: WeekMove) {
// anything past 60% is a data glitch until two sources agree
if (week.changePct > EXTREME_MOVE_PCT) return false;
// percentages lie on cheap cards; require real dollar movement
if (card.price < MIN_PRICE) return false;
if (Math.abs(week.changeUsd) < MIN_DOLLAR_MOVE) return false;
return true;
}
Why the pipeline looks like this now
The path every card takes before it reaches an inbox:
There's a manual admin path for previewing any of these to a real member, consent-checked on every attempt. Sends are manual for now; the first scheduled bundle run happens today, and the new SQL columns haven't faced production Postgres yet. I'd rather say that out loud than imply it's all proven.
Also last night
- The managed NAT gateway (about $40 a month) became a t4g.micro NAT instance (about $6) after a week of measured traffic showed peak load near 4 Mbps. The instance carries every private service's egress, not just the workers; that discovery alone justified the swap.
- Imports for members who turned their public showcase off used to crawl for 45 seconds, retry four times, and fail into an alarm. Now they're recognized and end as a private profile in about 10 seconds.
- The subject picker stopped letting one channel's posts hold back another channel's candidates. Instagram only cares about what Instagram posted.
- A VoiceOver fix: the What's New button now announces where it actually goes.
The lesson I'd keep from this one: when two sources disagree about something you're about to publish, the disagreement is the feature. Build the guard around it.