```html

Diagnosing a Stalled Funeral Home Outreach Campaign: Why Two Implementations Failed to Send a Single Email

We discovered a critical operational failure in our funeral home prospecting pipeline: despite two separate implementations built over months, zero emails had been sent to any of our 25+ qualified prospects. This post documents what we found, why both systems failed silently, and the architectural decisions needed to fix it.

The Discovery: Two Implementations, Zero Sends

A standing operational directive mandated a daily prospecting system: automatically discover 10 new funeral home contacts each day and send them a templated outreach email. After weeks of development, we audited the system and found:

  • Gmail audit (180-day span): Zero sends to any of the 25 funeral home prospects across domains like greenwoodsd.com, lavistamemorialpark.com, and neptunesandiego.com
  • Google Sheet tracking: All 25 contacts in `1ADx_0L6...rg38` (Contacts tab) had empty InitialSent, F1Sent, F2Sent, and Replied columns
  • SES audit: Zero sends from the mandated outreach@burialsatseasandiego.com address

The prospects weren't cold—they were never contacted at all.

Implementation #1: Google Apps Script — Built but Never Triggered

The first system was a Google Apps Script at `/sites/queenofsandiego.com/FuneralOutreach.gs`. It had solid architecture:

  • Prospect source: Google Sheet `1ADx_0L6...rg38`, Contacts tab (manually seeded with 25 funeral homes)
  • Send mechanism: AWS SES via SigV4 signature in-script, sender address admin@queenofsandiego.com
  • Cadence: Weekly trigger scheduled for Wednesday 9 AM PT, then per-prospect delays of Day 1, 4, 10, 19 (drip sequence)
  • Tracking: OOO detection, bounce handling, reply detection—the script had the right sophistication

Why it failed:

  1. No trigger installed. The CloudScheduler or Apps Script time-based trigger was never created. The code existed but had no execution context.
  2. Schema mismatch. The Google Sheet had only 9 columns, but the script's header documented a 14-column schema including OOO flags, bounce counts, and F3 tracking. The setup function funeralOutreachSetup() was never run to bridge the gap.
  3. Wrong sender domain. The script sent from admin@queenofsandiego.com, not the SES-verified outreach@burialsatseasandiego.com that the operational directive specified. This created domain reputation fragmentation.
  4. No audit trail. With no trigger and no manual invocations, the InitialSent column remained empty—impossible to debug what happened.

Implementation #2: EC2 Cron Job — Partially Built, Never Executed

The second system was a shell script on our EC2 instance: `/home/ubuntu/repos/tools/send_funeral_blast.sh`. This was meant to be the workhorse:

  • Cron spec: 5 16 24 4 * (April 24th only, 4:05 PM UTC)—not daily, not 10-per-day as mandated
  • Prospect source: CSV file at `tools/contacts/funeral-homes-sd.csv` with 8 hand-entered prospects (plus one self-test address)
  • Campaign invocation: jada_blast.py send --campaign funeral-outreach-2026, using template at `tools/templates/funeral-outreach.html`
  • Logging: Expected to write campaign ledger to `s3://progress.queenofsandiego.com/blast-campaigns.json`

Why it failed:

  1. Approval gate blocked silently. We found the campaign ledger at `s3://progress.queenofsandiego.com/blast-campaigns.json` contains 2,649 emails across 9 campaigns, but funeral-outreach-2026 is absent. The approval gate likely rejected the campaign (missing approval task m-2df1bcb8 in the done lane), and the cron job exited without logging the failure.
  2. No prospect discovery. The CSV was hand-curated—no automation to find 10 new prospects daily. The task that was supposed to exist simply doesn't.
  3. Wrong cadence. A one-time April 24 cron line is not a daily repeating job. This was either a test that got left in place or an incomplete migration.
  4. No monitoring. No funeral_blast_*.log files were found in `tools/logs/`. Either logging never initialized, or logs were deleted/rotated without archival.

What's Actually Missing: The Prospect Harvester

Neither implementation includes the core piece: automated discovery of 10 new funeral home prospects per day. This would require:

  • Google Maps API integration to search for funeral homes in San Diego county by category/keyword
  • Web scraping or data enrichment (LinkedIn Sales Navigator, Hunter.io, or similar) to extract contact emails
  • Deduplication logic against existing prospects in the Google Sheet
  • Automated insertion into the Contacts tab on a daily schedule

No code for this exists in the repository, on EC2, or in any Lambda function we could find.

Key Architectural Decisions Going Forward

1. Unified sender domain for reputation: All outreach must originate from outreach@burialsatasandiego.com, verified in SES. This prevents domain reputation fragmentation across admin@, info@, and other addresses.

2. Google Sheet as source-of-truth: The GAS implementation's architecture is sound—a shared Google Sheet as the prospect list is auditable, shareable with non-technical stakeholders, and integrates cleanly with SES via Apps Script. This should be the production path.

3. Daily cadence in Apps Script, not cron: Apps Script's time-based triggers are simpler than EC2 cron for business logic that needs to update a Google Sheet. They integrate natively with Google Workspace and avoid EC2 availability dependencies.

4. Prospect discovery as a separate service: Rather than embedding harvest logic in the send script, implement a standalone daily function (Lambda or Apps Script) that populates new prospects into the Contacts tab. Separation of concerns prevents blocking sends if harvesting fails.

5. Approval gates with logging: If campaign approval is required, ensure that rejections are logged prominently (email, Slack, CloudWatch) so silent failures don't happen again.

What's