Auditing a Stalled Funeral Home Outreach Campaign: Finding Zero Sends in a "Complete" System

When a standing operational rule says "send 10 new funeral home prospect emails daily," but effectiveness data shows zero sends across a 25-prospect list loaded weeks ago, something has broken silently. This post documents how we discovered two partially-built implementations, neither firing, and what that taught us about infrastructure hygiene in outreach automation.

The Setup: What We Inherited

The business goal was clear: identify funeral homes and burial service providers in the San Diego region, add them to a Google Sheet daily (10 per day), and trigger a multi-touch email sequence. The infrastructure to do this existed in three places:

  • Google Sheet (`1ADx_0L6...rg38`, tab `Contacts`): 25 manually-loaded prospects with columns for `InitialSent`, `F1Sent`, `F2Sent`, `Replied`—all empty.
  • Google Apps Script (`sites/queenofsandiego.com/FuneralOutreach.gs`): A 14-column schema with OOO and bounce detection, configured to send from `admin@queenofsandiego.com` via SES SigV4.
  • EC2 bash/Python (`/home/ubuntu/repos/tools/send_funeral_blast.sh` + `jada_blast.py`): A one-off cron job scheduled for Apr 24 only, pulling from `tools/contacts/funeral-homes-sd.csv` (8 contacts, one being a self-test address).

None of this was actually running.

Implementation #1: The Google Apps Script That Never Triggered

The GAS implementation in /sites/queenofsandiego.com/FuneralOutreach.gs was the more sophisticated of the two. It:

  • Reads from the Google Sheet using the Sheets API.
  • Calls funeralOutreachSetup() on first run to extend the sheet schema from 9 columns to 14 (adding `OOO`, `BounceType`, `LastF`, etc.).
  • Sends via AWS SES using SigV4 signing, with the sender hardcoded as `admin@queenofsandiego.com`.
  • Implements a widening gap cadence: Day 1 (initial), Day 4 (follow-up 1), Day 10 (follow-up 2), Day 19 (follow-up 3).
  • Detects OOO replies and bounce-backs to suppress further sends.

The problem: The trigger was never installed. The script has a line specifying weekly Wednesday 9 AM PT execution, but no Google Apps Script trigger exists in the project to invoke runFuneralOutreach(). Additionally, the sheet width is 9 columns, but the script expects 14—meaning funeralOutreachSetup() was never executed. If you audit the sheet columns, you'll see no `OOO`, `BounceType`, or `LastFollowUp` tracking columns exist.

This is a classic incomplete rollout: the code was written and tested locally, but the automation glue—the trigger—was never wired into the production Google Apps Script project.

Implementation #2: The EC2 Cron That Ran Once (or Never)

The second implementation lives on the EC2 instance and consists of:

  • Cron entry: `5 16 24 4 *` (4:05 PM UTC on Apr 24 of any year—essentially a one-time shot).
  • Script: /home/ubuntu/repos/tools/send_funeral_blast.sh, which calls jada_blast.py send --campaign funeral-outreach-2026.
  • Data source: tools/contacts/funeral-homes-sd.csv (8 records, hand-entered, including one self-test).
  • Template: tools/templates/funeral-outreach.html.

We audited the campaign ledger at s3://progress.queenofsandiego.com/blast-campaigns.json and found 2,649 emails sent across 9 distinct campaigns. The funeral-outreach-2026 campaign is not among them. Additionally, no log file matching funeral_blast_*.log exists in tools/logs/.

The most likely scenario: the cron job ran on Apr 24, invoked the send flow, but failed at an approval gate (the system requires manual task promotion in a Kanban-style workflow). The failure was silent, leaving no log and no send ledger entry.

Gmail Forensics: Zero Inbound Activity

We audited 180 days of Gmail history on the `jadasailing@gmail.com` account. Findings:

  • Outbound sends: 0 to any of the 25 funeral home prospects (no greenwoodsd.com, lavistamemorialpark.com, neptunesandiego.com, cafuneralt.com, or cortezcremations.com addresses).
  • Outbound from domain-specific addresses: 0 from `outreach@burialsatseasandiego.com` (the sender mandated by the standing rule).
  • Inbound replies: 0 from any prospect domain.

This confirms that neither the GAS nor the EC2 implementation ever produced a real send. The system was built but never activated.

Why This Matters: The Architecture Lesson

This scenario highlights a common failure mode in distributed automation:

  • Multiple implementations without coordination: Two separate teams (or phases) built two separate solutions to the same problem, neither knowing about the other. This creates maintenance debt and ambiguity about which system "owns" the process.
  • Local testing vs. production wiring: Code that works in development doesn't move to production without explicit trigger setup. In GAS, a trigger is not a code statement—it's a UI-driven registration that's easy to forget.
  • Silent failures in cron: A cron job that hits an approval gate and fails will not log to stdout/stderr by default. If the script doesn't explicitly write to a log file (and this one didn't), there's no audit trail of the failure.
  • Schema drift: The GAS script expects 14 sheet columns, but the sheet has 9. This mismatch should have been caught by a pre-flight validation that writes to a log or throws an exception early.

What's Next: Consolidation and Hardening

The path forward requires choosing a single implementation, hardening it, and instrumenting it with logging and alerts:

  • Recommendation: Use the GAS implementation (it's more maintainable and integrates natively with Google Workspace) but first:
    • Install the Google Apps Script trigger explicitly (weekly Wednesday 9 AM PT, or adjust to daily if the standing rule changes).
    • Call funeralOutreachSetup() once to extend