Last week I watched my site decide that a guy in New Jersey deserved an email about a Sara Landry show in Brooklyn, while another subscriber in Los Angeles heard nothing. That decision took about forty milliseconds, involved a geocoding API, a Haversine formula, and a cache table nobody will ever admire. This is how it works, including the part where I broke it before it ever sent a real email.
The site is thedjcalendar.com, and it tracks tour dates for hundreds of DJs. The alert system sounds simple: people subscribe to artists, artists announce shows, subscribers get emails. Simple until you mean it. Which artists? Which subscribers? What about the ones who only care within driving distance? What about the guy who unsubscribed but whose email still sits in three tables? This article is the tour of one pipeline that had to answer all of that with one rule: one pipeline, no legacy paths.
THE SHAPE OF IT
When my tour sync pulls fresh data for an artist, a diff function hashes every show into an event key - artist plus venue plus date, stable across sync runs. Those keys land in a "seen events" table. First time the system ever syncs an artist, everything goes to the seen table and nothing queues. That baseline rule is the only thing standing between a new subscriber and a mailbox full of every show their favorite artist has played since March.
Only genuinely new keys - a show that was never seen before - get inserted into a queue table. INSERT IGNORE, so double-syncs can't duplicate. The queue sits there, pending, until dispatch runs at the end of every sync.
Dispatch is the decider. For every pending queue row, it works through three audiences, and each one taught me something.
AUDIENCE ONE: MEMBERS
Signed-in members pick favorite DJs, and matching is a database LIKE against a JSON column - fast, crude, and fine at this scale. Before anyone gets mail, though, there's a suppression check against an unsubscribe table. One early lesson baked in here: check suppression on EVERY send path, not just the main one. My reminder cron had its own send loop that quietly skipped the check until I swept it.
AUDIENCE TWO: GUESTS WITH A CITY
The interesting one. Anyone can subscribe to artist alerts without an account - just an email. And recently I added the part I actually wanted: give us your city or ZIP (or your location), and we only email you about shows within 25 miles.
At dispatch time, for each guest subscriber, the queue row's city gets geocoded to coordinates via Open-Meteo's free geocoding API. First lookup hits the API; the answer is cached in a little city-key table so Brooklyn is only ever geocoded once in the site's lifetime. The subscriber's saved coordinates come from the same source, so comparing them is at least a fair fight. Then the honest part: the Haversine formula, seven lines of trigonometry, and a threshold of 40.2336 kilometers, because 25 miles is 40.2336 km and I refuse to pretend otherwise.
The cached-key trick is worth stealing: city keys are normalized (lowercase, strip everything non-alphanumeric) before hashing into the primary key, so "Brooklyn", "brooklyn " and "BROOKLYN" all share one cache row.
One deliberate decision worth confessing: if geocoding FAILS, I fail open - the subscriber still gets the email rather than silently missing shows. Missed emails are bugs people report; extra emails about an artist they chose are just email. Reasonable people could go the other way, but pick one on purpose.
AUDIENCE THREE: THE UNSUBSCRIBED
Every send path re-checks the unsubscribe table by lowercased email at send time, not just at list-build time. The unsubscribes table is small, indexed, and consulted like a bouncer who checks IDs twice.
THE BUG THAT LINT COULDN'T CATCH
Now the war story. The artist-page signup form needed to know which artist you're looking at. I injected a little block into artist.php right before the embed panel, using PHP short echo tags to print the artist's name and slug. Lint passed. Deployed. And the page said:
"Get an email the moment announces a new show."
Double space, no name. Because that region of artist.php sits inside a PHP NOWDOC - a literal string block where nothing executes. My PHP tags weren't code; they were text. Lint couldn't catch it because the file was syntactically perfect. PHP never parsed my tags because PHP never SAW them.
The fix is the more useful lesson: inside a region that can't execute PHP, don't ship PHP. The rebuilt block is pure HTML and JavaScript. The artist slug comes from the URL path (with a fallback to an existing widget link on the page), the display name comes from the page's h1. Zero server cooperation required. Lint validates syntax, never context - the only test that matters is reading the SERVED HTML.
THE DATA BUG LINT COULDN'T CATCH EITHER
While probing for the test design, I found a second, quieter bug. The guest matcher compares the queue's artist slug (think "davidguetta") against each subscriber's artist list, lowercased and comma-split. My early subscribers had been saved with DISPLAY names - "David Guetta", "Armin van Buuren" - which can never equal "davidguetta". Four people subscribed to four artists and would never have received a single email.
No crash, no error, no lint. Just a contract mismatch between the code that WRITES subscriptions and the code that READS them. The fix was a migration worker mapping display names to slugs, verified against the site's artist data. The lesson is the one I keep relearning: when a feature silently does nothing, suspect a contract mismatch between writer and reader, and go read both sides.
HOW I TESTED A MAILER WITHOUT SPAMMING ANYONE
The scariest part of any alert system is testing it. If I queued a fake show for a real artist, real subscribers would get an email about a show that doesn't exist. So the test worker ran a drill with an airlock:
SAFETY GATE FIRST: query every member whose favorites match the test artist. If ANY address outside my own four inboxes matched, abort untouched. Zero strangers got fake-show mail by construction.
Hold any unrelated pending queue rows so the test fires the real dispatcher without touching real work (restored afterward).
Three test subscribers, one fake queued show in Brooklyn (roughly 7.9 km from New York): one subscriber in New York who should get mail, one in Los Angeles 3,900 km away who should be radius-skipped, and one address sitting in the unsubscribe table who should be suppressed.
Fire the REAL dispatcher - the same code production uses, no test double.
Assert on the evidence: the sent-log had exactly one row, keyed to the New York subscriber with a negative ID namespace guests use. Los Angeles and the unsubscribed address were absent. The geocode cache had Brooklyn's coordinates. Then everything - test rows, sent-log entries, held queue rows - was deleted or restored.
The dispatch result printed: processed 1, emails 1. Exactly one email, to exactly the right person, and I have the log row to prove it. That single assertion told me the radius math works, the exclusion works, suppression works, and the dedup log writes - four claims, one drill.
THE FULL LOOP
Capture (pure-HTML form, client geocoding, coordinates validated and bounded server-side) -> save -> diff (stable event keys, first-sync baseline) -> queue (INSERT IGNORE dedup) -> dispatch (members, guests, radius, suppression, sent log) -> retire the legacy sender so there is exactly one pipeline to reason about.
That last part deserves its own sentence: before this, a second legacy sender could reach some of the same people through a different path with different rules. Two pipelines that disagree is worse than one pipeline that's wrong - you can't even tell which one sent the email you're debugging.
WHAT I'D TELL ANYONE BUILDING THIS
- Diff on stable keys, and baseline the first sync. This is the difference between an alert system and a spam cannon.
- Cache your geocoding. Same city, same answer, forever - never pay twice.
- Check unsubscribe at send time, on every path.
- Fail open or fail closed on purpose, and write down which.
- Lint proves nothing about context. Read the served output.
- Test the real dispatcher with quarantined data, never a mock of the dispatcher.
- When something silently does nothing, audit the writer/reader contract.
The whole thing is hand-built PHP and MySQL - no frameworks, no queues-as-a-service, no cron daemon (dispatch rides the sync that feeds it). If that sounds like your kind of stubbornness, the site is thedjcalendar.com and I write up more of these builds as they ship.