Diagnosing a Silent Failure: Why Our Funeral Home Outreach Campaign Never Sent a Single Email
Last week, we discovered that a funeral home prospecting campaign—built across two separate implementations and sitting in production for weeks—had sent exactly zero emails. This post walks through the forensic process we used to find out why, the architectural decisions that led to the failure, and the framework we're now building to prevent this in future outreach initiatives.
The Symptom: Zero Sends, Full Sheet
Our funeral home outreach system has 25 prospects loaded into a Google Sheet (1ADx_0L6...rg38, tab Contacts) with contact details, but every tracking column—InitialSent, F1Sent, F2Sent, Replied—remained empty. A 180-day Gmail audit of jadasailing@gmail.com and the intended sender domains (outreach@ and info@burialsatseasandiego.com) confirmed zero outbound sends and zero inbound replies.
This should have been impossible. We had code. We had prospects. We had SES credentials. But nothing happened.
Root Cause #1: The GAS Implementation Was Never Initialized
The primary implementation lives in sites/queenofsandiego.com/FuneralOutreach.gs. This Google Apps Script:
- Reads the 25-prospect sheet via Google Sheets API
- Sends via SES using SigV4 signing directly in the script (bypassing SMTP)
- Implements multi-touch drip logic: Day 1 → Day 4 → Day 10 → Day 19 follow-ups
- Tracks OOO replies and hard bounces to suppress future sends
- Runs on a weekly Wednesday 9 AM PT trigger
The code is well-written, but the funeralOutreachSetup() function—which initializes the sheet schema—was never executed. The script header declares a 14-column schema (including OOO, Bounced, F3Sent), but the actual sheet has only 9 columns. This mismatch meant that even if the trigger fired, column references in the send loop would map to wrong cells.
More critically: the Apps Script trigger itself was never installed. A development instance may have had a manual trigger in the editor, but no time-based trigger was saved in the production sheet's triggers list.
Root Cause #2: The EC2 Fallback Was a One-Shot That Failed Silently
A second implementation exists on EC2: a bash wrapper at /home/ubuntu/repos/tools/send_funeral_blast.sh that invokes a Python campaign sender:
jada_blast.py send --campaign funeral-outreach-2026 \
--contacts-file tools/contacts/funeral-homes-sd.csv \
--template tools/templates/funeral-outreach.html
This was scheduled as a one-time cron job: 5 16 24 4 * (April 24, 4:05 PM UTC). The contacts file has 8 manually-entered funeral home addresses plus one self-test address (c.b.ladd@gmail.com).
Checking the campaign ledger in S3 (s3://progress.queenofsandiego.com/blast-campaigns.json) shows 2,649 emails sent across 9 other campaigns, but funeral-outreach-2026 does not appear. Checking tools/logs/funeral_blast_*.log yields nothing. The send likely failed at an approval gate—our jada_blast.py implementation requires a manual task state transition in our workflow system before committing sends to SES, presumably to catch errors. That approval was never granted, and the cron job exited silently without logging the failure visibly.
Architectural Issues: Why This Happened
Three design decisions set us up for this failure:
- Dual implementations with no clear ownership. The GAS script and the EC2 cron job were built to solve the same problem but never unified. No documentation made clear which was primary or how they were meant to coexist.
- No monitoring on trigger state. We don't alert when a scheduled trigger doesn't fire. The Apps Script had no installed trigger and nobody checked. The EC2 cron had no output forwarded to a log aggregator.
- Silent failures in approval workflows. When
jada_blast.pyhit the approval gate and exited, it didn't fail the cron job—it just stopped. No email, no error page, no CloudWatch log entry signaling that human approval was needed. - Manual prospect loading.** There's no daily harvester finding new funeral home emails. The task description mentioned "10 new emails every day," but no code exists to do this. The 25 prospects were hand-entered weeks ago and never refreshed.
The Audit Trail: How We Found This
Our debugging process followed a standard forensic pattern:
- Gmail audit using date ranges and recipient filters to confirm zero sends
- Sheet inspection to check data freshness and schema consistency
- Script validation: reading the GAS code and checking the
Utilities.sleep()delays, the SES SigV4 signing logic, and the regex for reply detection - Apps Script project inspection: listing installed triggers (found none)
- EC2 log archeology: checking cron logs, campaign ledgers, and output files
- S3 manifest cross-reference: verifying that no partial sends were recorded in the campaign ledger bucket
Each step narrowed the possibility space until we had two confirmed root causes: one implementation never triggered, the other hit a blocking approval gate.
Path Forward: The Unified Outreach Framework
We're consolidating into a single, observable system:
- Primary: GAS for sheet-driven campaigns. We'll fix the schema (execute
fuleralOutreachSetup()to widen the sheet), install the time-based trigger, and switch the sender tooutreach@burialsatseasandiego.com(which has SES sending permissions). This gives us single-system-of-record tracking in a sheet that non-engineers can read. - Prospect discovery: Python harvester. A new script will run daily, hit Google Maps API for funeral homes in target geographies, deduplicate against the sheet, and append new rows. This moves us from manual-list to continuous-discovery.
- Monitoring: CloudWatch + SNS. Apps Script execution will log to Cloud Logging; we'll ingest those logs to CloudWatch and alert on trigger failure or send exceptions.
- Decommission EC2 cron. The one-shot implementation was meant as a fallback. Once GAS is live, we'll remove the
send_funeral_blast.shreference and the associated cron line.
The post-mortem here is simple: infrastructure without observability fails invisibly. Our next outreach system will have logging baked in from day one.