SOPs
Notify-James Pattern — Send a Resend Email From Any Script
Why this exists
Section titled “Why this exists”When a script needs to tell James something happened — a build patched, a daily digest is ready, a CRM contact came in hot — it should not invent its own email path. There is one canonical pattern for “fire an email from a script”, and it lives in a single helper.
This page is that pattern, named so it is easy to point Claude at: “use the notify-James pattern for this alert.”
The diagram
Section titled “The diagram”
Three callers (any script under ~/apps/) shell out to the helper. The helper hits Resend over HTTPS. Resend signs the message with the jameshurst.com DKIM key and delivers as Claude Code <claude@jameshurst.com>. James reads it in his normal Gmail inbox like any other email.
The four things to remember
Section titled “The four things to remember”| Anchor | Value |
|---|---|
| Repository | ~/apps/gmail-notify/ (NOT gmail-helper — that one reads the inbox, opposite job) |
| Helper script | ~/apps/gmail-notify/send-email.py |
| From display name | Claude Code |
| From email | claude@jameshurst.com |
| To (default) | ojhurst@gmail.com |
| Service | Resend (HTTPS POST to api.resend.com/emails) |
| Auth | RESEND_API_KEY env var (fallback baked into the script) |
| DKIM domain | jameshurst.com (signed by Resend on James’s behalf) |
Repo names — easy to confuse
Section titled “Repo names — easy to confuse”There are two repos with similar names that do opposite jobs:
~/apps/gmail-helper/— INBOUND. Reads the inbox, searches, archives, triages. OAuth-based. Used by/gmailskill, inbox triage flows.~/apps/gmail-notify/— OUTBOUND. Sends email out via Resend. Used by every “alert me when X happens” automation.
The mnemonic: Helper reads. Notify sends.
How to call it
Section titled “How to call it”Shell out from any script. Plain text:
python3 ~/apps/gmail-notify/send-email.py \ --to "ojhurst@gmail.com" \ --subject "Subject line" \ --body "Plain text body"Stdin works too:
echo "Body from stdin" | python3 ~/apps/gmail-notify/send-email.py \ --to "ojhurst@gmail.com" \ --subject "Subject line"HTML body (with optional plain-text fallback):
python3 ~/apps/gmail-notify/send-email.py \ --to "ojhurst@gmail.com" \ --subject "Subject" \ --html "<h1>Rich body</h1><p>...</p>" \ --body "Plain-text fallback"Override the from address with --sender if a specific automation needs its own display name. Default is Claude Code <claude@jameshurst.com>.
When to send an email vs. stay silent
Section titled “When to send an email vs. stay silent”The right pattern: only email when something actually changed or actually needs attention. Cron jobs that fire every minute should not email every minute. Wrap the send in a “did anything happen” check:
OUTPUT=$(run_the_thing)if echo "$OUTPUT" | grep -q "PATCHED:"; then python3 ~/apps/gmail-notify/send-email.py --to ojhurst@gmail.com --subject "..." --body "$OUTPUT"fiAdd a cooldown lock in /tmp/<job>.email.lock for jobs whose triggers can fire in bursts (launchd WatchPaths is the typical case — multiple file events per real change).
Existing callers (proof this is the well-trodden path)
Section titled “Existing callers (proof this is the well-trodden path)”Every one of these scripts uses ~/apps/gmail-notify/send-email.py:
~/apps/utils/claude-code-vscode/watch-and-patch.sh— VS Code extension auto-update notifications~/apps/claude-code-crm/notify.py— Go-Kart Park CRM alerts~/apps/claude-code-crm/morning_digest.py— daily owner brief~/apps/claude-code-crm/daily_review.py~/apps/claude-code-crm/messaging.py~/apps/cloudflare-audit/audit.py~/apps/pokemasters-blog/scripts/daily-stats-email.py~/apps/meal-tracker/bin/digest.py~/apps/gkp-facebook-ad-scheduler/toggle.py
When adding a new “tell me when X happens” automation, copy from one of these — do not write a new email path.
Capture mode (for digest aggregation)
Section titled “Capture mode (for digest aggregation)”send-email.py honors DIGEST_CAPTURE_DIR — when set, it writes the email payload to a JSON file in that directory instead of sending. daily-digest.py uses this to consolidate many small sub-emails into one daily digest. Useful when you want to aggregate rather than spam.
Underlying infrastructure
Section titled “Underlying infrastructure”The “why we use Resend at all, and how DKIM is set up” pattern is documented separately in Custom Domain Email — Cloudflare Routing + Resend + Gmail Alias. That page covers the broader stack (inbound routing via Cloudflare + outbound via Resend + Gmail alias for compose). This page is purely about the outbound helper.
Failure modes
Section titled “Failure modes”RESEND_API_KEYinvalid or missing → script exits 1 with the Resend error body printed to stderr. Cron jobs should pipe stderr to a log so the failure is visible.- Recipient bounces / Resend reputation issue → check the Resend dashboard for delivery status. Free tier is 3,000 emails/month — only relevant if a runaway loop sends thousands.
- DKIM domain ever changes → update the Resend domain config; the from-address constant in
send-email.pydoes not need to change as long as the domain isjameshurst.com.
Relationship to memory and skills
Section titled “Relationship to memory and skills”The string “notify-James pattern” is a specific phrase Claude Code knows. Saying “use the notify-James pattern for this” in a task is unambiguous — it resolves to this page.