Skip to content

Instantly share code, notes, and snippets.

@BrennerSpear
Last active April 18, 2026 00:02
Show Gist options
  • Select an option

  • Save BrennerSpear/10acd6180ca9d10d53b5f703e16855fc to your computer and use it in GitHub Desktop.

Select an option

Save BrennerSpear/10acd6180ca9d10d53b5f703e16855fc to your computer and use it in GitHub Desktop.
Hermes email-triage template with helper scripts

Hermes Email Triage Template

This gist is a template for a Hermes email-triage skill and its helper scripts.

What is included

  • email-triage.SKILL.md — the main skill instructions
  • email-triage.heuristics.md — categorization heuristics/reference material
  • email-triage-fetch.sh — fetch unlabeled emails for triage
  • extract-newsletter-url.sh — extract canonical article URLs from newsletter emails
  • build-digest-email.sh — build an HTML digest email
  • fetch-sender-logo.sh — fetch and cache sender logos

How to set up Gmail Multiple Inboxes

In Gmail:

  1. Go to Settings → See all settings → Inbox.
  2. Set Inbox type to Multiple Inboxes.
  3. Add these sections:
    • label:Login in:inboxLogin
    • label:Respond in:inboxRespond
    • label:Action-Required in:inboxAction Required
    • label:Review in:inboxReview
    • label:Update in:inboxUpdate or Newsletter
  4. Set Maximum page size to 20 conversations.
  5. Set Multiple inbox position to Above the inbox.
  6. Save changes.

How to use the template

  • swap in your own account names, labels, and workflow choices
  • adapt the heuristics to your inbox and preferences
  • use the scripts as starting points for your own automation

Notes

This version uses placeholder identities and dummy addresses such as user@example.com. It is intended as a reusable example, not a live production config.

Setup screenshot

Gmail multiple inbox setup screenshot

The screenshot file is also included separately in this gist as Z-GMAIL-MULTIPLE-INBOXES-SETUP.svg.

#!/bin/bash
# Build HTML digest emails with inline sender logos
# Usage: build-digest-email.sh <output.html> <<< "section|domain|name|subject|url|summary|read_time"
#
# Input format (pipe-delimited, one per line):
# section|domain|display_name|subject|url|summary|read_time
#
# For newsletter section: all fields used (summary + read_time shown)
# For updates section: summary + read_time optional (omitted = simple row)
#
# Output: HTML file ready for gog gmail send --body-html
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
LOGO_DIR="${WORKSPACE_DIR}/data/sender-logos"
FETCH_SCRIPT="${SCRIPT_DIR}/fetch-sender-logo.sh"
OUTPUT="${1:?Usage: build-digest-email.sh <output.html>}"
# Read entries from stdin
entries=()
while IFS= read -r line; do
[ -n "$line" ] && entries+=("$line")
done
# Get date for header
DATE_STR="${2:-$(date '+%b %d')}"
# Start HTML
cat > "$OUTPUT" <<HEADER
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#ffffff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;margin:0 auto;">
HEADER
section=""
item_count=0
for entry in "${entries[@]}"; do
IFS='|' read -r type domain name subject url summary read_time <<< "$entry"
item_count=$((item_count + 1))
# Section header when type changes
if [ "$type" != "$section" ]; then
section="$type"
cat >> "$OUTPUT" <<SECTION
<tr>
<td style="padding:20px 12px 8px;">
<span style="font-size:18px;font-weight:700;color:#1a1a1a;">${section}</span>
<span style="color:#999;font-size:14px;font-weight:400;"> &mdash; ${DATE_STR}</span>
</td>
</tr>
SECTION
fi
# Get or fetch logo
logo_file=""
# Try exact domain first, then parent domain
if [ -f "${LOGO_DIR}/${domain}.png" ]; then
logo_file="${LOGO_DIR}/${domain}.png"
else
parent=$(echo "$domain" | awk -F. '{if(NF>2) print $(NF-1)"."$NF; else print $0}')
if [ -f "${LOGO_DIR}/${parent}.png" ]; then
logo_file="${LOGO_DIR}/${parent}.png"
elif [ -x "$FETCH_SCRIPT" ]; then
"$FETCH_SCRIPT" "$domain" >/dev/null 2>&1 || true
if [ -f "${LOGO_DIR}/${domain}.png" ]; then
logo_file="${LOGO_DIR}/${domain}.png"
fi
fi
fi
# Build logo HTML
if [ -n "$logo_file" ] && [ -f "$logo_file" ]; then
b64=$(base64 < "$logo_file" | tr -d '\n')
logo_html="<img src=\"data:image/png;base64,${b64}\" width=\"32\" height=\"32\" style=\"width:32px;height:32px;border-radius:6px;object-fit:cover;display:block;\" alt=\"${name}\">"
else
letter=$(echo "$name" | cut -c1 | tr '[:lower:]' '[:upper:]')
# Deterministic color from name
hash_val=$(echo -n "$name" | cksum | awk '{print $1}')
colors=("#4A90D9" "#D94A4A" "#4AD98F" "#D9A54A" "#9B59B6" "#1ABC9C" "#E74C3C" "#3498DB")
color_idx=$((hash_val % 8))
color="${colors[$color_idx]}"
logo_html="<div style=\"width:32px;height:32px;border-radius:6px;background:${color};text-align:center;line-height:32px;font-size:16px;font-weight:600;color:#fff;\">${letter}</div>"
fi
# Use URL if provided, otherwise fall back to # (no link)
link="${url:-#}"
# Build row based on whether we have a summary (newsletter style) or not (update style)
if [ -n "$summary" ] && [ "$summary" != " " ]; then
# Newsletter style: logo + sender + read time + subject + summary
read_time_html=""
if [ -n "$read_time" ] && [ "$read_time" != " " ]; then
read_time_html="<span style=\"color:#999;font-weight:400;font-size:12px;\"> &middot; ${read_time}</span>"
fi
cat >> "$OUTPUT" <<ROW
<tr>
<td style="padding:10px 12px;" valign="top">
<table cellpadding="0" cellspacing="0" border="0" width="100%"><tr>
<td valign="top" width="32" style="padding-right:12px;padding-top:2px;">${logo_html}</td>
<td valign="top" style="font-size:14px;line-height:1.45;">
<a href="${link}" style="text-decoration:none;color:#1a1a1a;">
<span style="font-weight:700;">${name}</span>${read_time_html}
</a>
<br/>
<a href="${link}" style="text-decoration:none;color:#1a1a1a;">
<span style="font-weight:500;">${subject}</span>
</a>
<br/>
<span style="color:#666;font-weight:400;font-size:13px;line-height:1.4;">${summary}</span>
</td>
</tr></table>
</td>
</tr>
<tr><td style="padding:0 12px;"><hr style="border:none;border-top:1px solid #f0f0f0;margin:0;"/></td></tr>
ROW
else
# Update style: logo + bold sender + subject (no summary)
cat >> "$OUTPUT" <<ROW
<tr>
<td style="padding:7px 12px;" valign="middle">
<table cellpadding="0" cellspacing="0" border="0" width="100%"><tr>
<td valign="middle" width="32" style="padding-right:12px;">${logo_html}</td>
<td valign="middle" style="font-size:14px;line-height:1.3;">
<a href="${link}" style="text-decoration:none;color:#1a1a1a;">
<span style="font-weight:600;">${name}</span>
<span style="color:#5f5f5f;font-weight:400;"> &mdash; ${subject}</span>
</a>
</td>
</tr></table>
</td>
</tr>
ROW
fi
done
# Footer
cat >> "$OUTPUT" <<FOOTER
<tr>
<td style="padding:16px 12px;text-align:center;">
<span style="font-size:12px;color:#b0b0b0;">Digest by Demo Bot &middot; ${item_count} items</span>
</td>
</tr>
</table>
</body>
</html>
FOOTER
echo "$OUTPUT"
#!/bin/bash
# email-triage-fetch.sh — Fetch unlabeled inbox emails for triage
# Usage: email-triage-fetch.sh [--limit N] [--account EMAIL] [--page TOKEN]
#
# Returns only emails that have NO triage label applied yet.
# Uses "has:nouserlabels" to exclude ALL user-labeled emails,
# then pipes through a secondary filter to catch any leaks.
set -euo pipefail
LIMIT=50
ACCOUNT="user@example.com"
PAGE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--limit) LIMIT="$2"; shift 2 ;;
--account) ACCOUNT="$2"; shift 2 ;;
--page) PAGE="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
# All triage labels to exclude — use NOT operator (gog CLI doesn't handle -label: correctly)
TRIAGE_LABELS=(
"Auto-Archived"
"Unsubscribed"
"Newsletter"
"Update"
"Respond"
"Action Required"
"To Read"
"To Archive"
"To Unsubscribe"
"Login"
"Unsubscribe"
)
# Build query using NOT label:X (works correctly with gog CLI)
QUERY="in:inbox"
for label in "${TRIAGE_LABELS[@]}"; do
QUERY="$QUERY NOT label:${label// /-}"
done
# Build command
CMD=(gog gmail search "$QUERY" --account "$ACCOUNT" --plain --limit "$LIMIT")
if [[ -n "$PAGE" ]]; then
CMD+=(--page "$PAGE")
fi
# Run search and post-filter: remove any line containing a triage label name
# (belt and suspenders — catches any Gmail search quirks)
OUTPUT=$("${CMD[@]}" 2>/dev/null)
# Normalize the emitted pagination hint so callers can pass the raw token back to --page.
OUTPUT=$(printf '%s\n' "$OUTPUT" | sed -E 's/^# Next page: --page /# Next page: /')
# Print header (skip any leading pagination hint from gog)
HEADER=$(printf '%s\n' "$OUTPUT" | awk '!/^# Next page:/{print; exit}')
echo "$HEADER"
# Extract and print the pagination hint explicitly.
printf '%s\n' "$OUTPUT" | awk '/^# Next page:/{sub(/^# Next page: --page /, "# Next page: "); print}' || true
# Filter body: exclude lines only when the LABELS column contains an actual triage label.
# Preserve Gmail category labels like CATEGORY_UPDATES, which should not match "Update".
BODY=$(printf '%s\n' "$OUTPUT" | awk 'NR > 1 && !/^# Next page:/')
echo "$BODY" | awk -F'\t' '
BEGIN {
split("Auto-Archived|Unsubscribed|Newsletter|Update|Respond|Action Required|To Read|To Archive|To Unsubscribe|Login|Unsubscribe", arr, "|")
for (i in arr) blocked[arr[i]] = 1
}
{
labels = $5
n = split(labels, parts, ",")
keep = 1
for (i = 1; i <= n; i++) {
gsub(/^ +| +$/, "", parts[i])
if (parts[i] in blocked) {
keep = 0
break
}
}
if (keep) print
}
' || true

Email Triage Heuristics

Decision rules for categorizing emails. Every email gets exactly one label.

Senders we've already unsubscribed from or set up auto-archive filters for are handled by Gmail filters — they won't appear in triage. These heuristics are for NEW emails that make it through.

Category Decision Tree

Ask these questions in order:

  1. Is the email FROM the user's own address? (for example: user@example.com or ops@example.com) → SKIP — do not label. This is an outbound/sent email that hit the webhook.
  2. Is it a login link, OTP, 2FA code?Login
  3. Does the user need to DO something? (pay, renew, fix, file, comply, accept/decline invite) → Action Required
  4. Does the user need to REPLY to a person?Respond
  5. Is the sender trying to sell something the user didn't ask for?To Unsubscribe
  6. Is it a recurring machine-generated notification? (receipts, statements, tracking, scores, digests) → To Archive
  7. Is it editorial/opinion content from a publication?Newsletter
  8. Is it a product changelog, service notification, event confirmation, or receipt?Update
  9. Is it interesting one-off content worth reading, or you're unsure?Review

To Unsubscribe — Signals

  • Marketing from brands the user hasn't bought from recently
  • Financial clickbait newsletters (unicode obfuscation, fear-mongering subjects)
  • Wellness/fitness studios outside the user's area
  • Services the user doesn't use
  • Restaurant loyalty blasts from one-time orders
  • Re-engagement spam ("we miss you!", "your account misses you")
  • Dev tool marketing the user doesn't read (product promos, not changelogs)
  • Promo emails from services the user DOES use — unsubscribe the marketing address, keep the service address
  • Scam/phishing emails (fake "unclaimed tokens", crypto scams)
  • Senders with no unsubscribe header → block via auto-archive filter instead

To Archive — Signals

  • Transaction receipts (crypto, bank transfers, withdrawals)
  • Statement notifications ("statement is available", "payment scheduled")
  • Credit score updates (FICO, Experian)
  • Utility usage reports (electricity, gas)
  • Flight receipts
  • Check-in reminders
  • Bank connection confirmations (Plaid)
  • Shipping/delivery tracking
  • Automated monitoring alerts
  • Social media digest notifications ("recently posted")
  • Ride receipts (Lyft, Uber, Citi Bike)
  • Order confirmations and return notifications
  • Payment confirmations from known services
  • Class/workout booking confirmations and receipts

Filter rule: If a sender also sends emails the user needs (e.g., bank sends statements AND fraud alerts), the filter MUST include a subject pattern. Never blanket-filter mixed senders.

Newsletter — Signals

Newsletter = someone WROTE an article/essay/analysis you'd sit down and READ. The test: "Could this be a blog post?" If yes → Newsletter. If no → Update.

  • Editorial/opinion content from publications the user subscribes to
  • Industry thought leadership essays (AI, crypto, VC, tech)
  • Community or local-interest writing
  • Content creator newsletters (podcasters, writers, analysts)
  • Substack posts, Beehiiv newsletters, curated roundups with commentary

NOT Newsletter (these are Update):

  • Product feature announcements (IconScout, Mercury, Base App) → Update
  • Service promos or savings offers (Amex APY, credit card offers) → Update
  • Home/travel exchange listings (Kindred, TrustedHousesitters, ThirdHome) → Update
  • Event announcements without editorial content (Tech Week, hackathons) → Update
  • Account linking or platform changes (Glassdoor, GitHub policy) → Update
  • Ticket/concert alerts (Spotify, Live Nation) → Update
  • Food/product availability alerts (Maui Nui, meal kits) → Update
  • Recruiting outreach → Update

The distinction: Newsletter has ORIGINAL WRITTEN CONTENT you'd read for insight. Update is a NOTIFICATION from a service/platform about something happening.

Update — Signals

  • Product changelogs and feature announcements
  • Service invoices/receipts (Vercel, etc.)
  • Event invitations and RSVPs (Luma, Cerebral Valley)
  • Reservation confirmations (restaurants, travel)
  • Platform migration/policy/ToS changes
  • Event/community logistics and coordination
  • Forwarded emails from family members — informational
  • Security notifications ("new login", "OAuth app added") — FYI, not actionable
  • Cold recruiter outreach (informational, might be useful — not Unsubscribe)
  • Payment confirmations where amounts vary (not auto-archivable)
  • Home/travel exchange listings and availability alerts
  • Ticket sale notifications and concert alerts
  • Product promo emails from services the user DOES use (not editorial content)
  • Food/product availability or restock alerts

Review — Signals

  • Interesting one-off content that isn't from a recurring newsletter
  • Community updates from people/orgs the user follows
  • FYI emails worth knowing about but not urgent
  • Default bucket when unsure — better to flag for review than miscategorize
  • Emails from unknown senders where intent is unclear

Action Required — Signals

  • Failed payments, subscription endings
  • Tax documents (K1s, 1099s, etc.)
  • Permit/compliance deadlines
  • Trial expirations
  • Bank/credit union account changes needing review
  • Anything where inaction has consequences
  • First-time emails from new financial services — treat as action item until understood
  • All calendar invites — meeting invites, event invitations, scheduling requests
  • Overdue payment reminders

Respond — Signals

  • From a real human expecting a reply
  • The user addressed directly by name
  • Active conversation thread

Login — Signals

A deterministic Gmail filter handles most Login emails automatically. It matches subject patterns for codes/links:

  • verification code, security code, login code, single-use code
  • one-time passcode/password/code, OTP, passcode
  • "Your code is", "Your code -", Login Verification
  • sign-in link, magic link, login link, password reset

NOT Login (these are Updates):

  • "new login", "new sign-in", "new sign-on" — FYI security notifications, not codes
  • GitHub OAuth app added, account activity alerts

Login = the user needs to copy a code or click a link to authenticate. "Someone logged in" = Update.

Anti-patterns

  • Never apply Unsubscribed label unless unsubscribe actually succeeded. If no unsubscribe link exists, block via auto-archive filter instead.
  • Never filter auth/login emails even if the sender also sends archivable content.
  • Never blanket-filter a mixed sender — always require a subject pattern.
  • Never unsubscribe from a sender the user actively engages with. Check from:me to:<address> if unsure.
  • Don't auto-archive event invites even if they look promotional.
  • Don't auto-archive tax documents — they need action.
  • Don't auto-archive bank account change notifications — they need review.
  • Family-member emails = always Respond or Update, never anything else.
  • Local studios/gyms the user actively attends = keep, never unsubscribe.
  • Calendar invites from anyone = Action Required (needs accept/decline).
  • Informational market commentary (e.g., ISG Client Calls) = Update, not Action Required.
  • Membership communications from services the user pays for = Update, not Unsubscribe.
name email-triage
description Triage Gmail inbox — categorize and label every unread email, propose unsubscribes and auto-archive filters for approval. Use when asked to filter emails, triage inbox, clean up email, unsubscribe from senders, or create Gmail skip-inbox filters. Triggers on "filter my emails", "triage my inbox", "clean up my email", "unsubscribe", "email cleanup".

Email Triage

Every unread inbox email gets exactly one label. No email leaves without a category.

Labels

Content labels — one per email:

  • Respond (Label_58) — needs a reply from the user
  • Action Required (Label_59) — something to do, not a reply
  • Review (Label_65) — worth a look but not urgent; use when unsure about category
  • Newsletter (Label_60) — subscribed editorial/opinion content
  • Update (Label_61) — product updates, event invites, confirmations, receipts, service notifications
  • Login (Label_66) — OTPs, login links, 2FA, "new login" alerts

Staging labels — propose, execute on approval:

  • To Archive (Label_56) — proposed for auto-archive filter
  • To Unsubscribe (Label_62) — proposed for unsubscribe

Final state labels — applied after action:

  • Auto-Archived (Label_63) — baked into Gmail filter (skip inbox + add label)
  • Unsubscribed (Label_64) — only if unsubscribe actually succeeded

Workflow

1. Fetch

Use the triage fetch script (handles label filtering correctly):

~/.hermes/workspace/scripts/email-triage-fetch.sh --limit 50 --account user@example.com

Important: The gog CLI does NOT handle -label:X syntax correctly — it can actually include matching results instead of excluding them. The script uses NOT label:X (which works) plus a post-filter as belt-and-suspenders.

For pagination, pass --page TOKEN from the previous result's # Next page: line.

2. Categorize

Read email-triage.heuristics.md and assign every email exactly ONE label. Use the decision tree.

3. Present

Show all emails grouped by category. Include:

  • Sender name + email
  • Subject line
  • Category + brief reason

For To Archive: include proposed filter (from:X subject:"Y")

Every email must have a category. No "Keep unlabeled" bucket.

Wait for the user's approval before executing.

4. Execute

All emails: Apply their label.

gog gmail thread modify <threadId> --add "<Label>" --account user@example.com

Approved To Unsubscribe:

# Extract unsubscribe URL
gog gmail read <id> --account user@example.com --json | python3 -c "
import json, sys, re
data = json.load(sys.stdin)
for msg in data.get('thread',{}).get('messages',[]):
    headers = msg.get('payload',{}).get('headers',[])
    unsub = next((h['value'] for h in headers if h['name']=='List-Unsubscribe'), None)
    if unsub:
        urls = re.findall(r'<(https://[^>]+)>', unsub)
        if urls: print(urls[0])
"

# Hit URL (GET first, POST if 404, browser if 403/Beehiiv)
curl -sL -o /dev/null -w "%{http_code}" "<url>"

# Only if unsubscribe succeeded:
gog gmail thread modify <threadId> --add "Unsubscribed" --remove "To Unsubscribe" --remove "INBOX" --account user@example.com

# ALWAYS create a canary filter for the sender after unsubscribing.
# This catches senders who ignore the unsubscribe — their emails skip inbox
# and get tagged "Unsubscribed" so we can spot repeat offenders.
gog gmail settings filters create \
  --from="sender@example.com" \
  --archive \
  --add-label "Unsubscribed" \
  --account user@example.com

# If no unsubscribe link exists, block via auto-archive filter instead:
gog gmail settings filters create \
  --from="sender@example.com" \
  --archive \
  --add-label "Auto-Archived" \
  --account user@example.com
gog gmail thread modify <threadId> --add "Auto-Archived" --remove "To Unsubscribe" --remove "INBOX" --account user@example.com

Approved To Archive:

# Create filter (always include Auto-Archived label)
gog gmail settings filters create \
  --from="sender@example.com" \
  --subject="pattern" \
  --archive \
  --add-label "Auto-Archived" \
  --account user@example.com

# Swap label + archive existing
gog gmail thread modify <threadId> --add "Auto-Archived" --remove "To Archive" --remove "INBOX" --account user@example.com

Digest Email

After triage, send a rich HTML digest email (from digest-bot@example.com to user@example.com) with two sections:

Newsletters Section

Each newsletter gets: sender logo (inline base64), bold sender name, read time estimate, subject, and a 1-2 sentence AI summary. Links go to the actual article URL (not Gmail), extracted via:

~/.hermes/workspace/scripts/extract-newsletter-url.sh <messageId> user@example.com

Updates Section

Each update gets: sender logo, bold sender name, subject. Links go to Gmail thread (updates don't have article pages).

Building the digest

Use build-digest-email.sh with pipe-delimited input:

section|domain|display_name|subject|url|summary|read_time

Newsletters: all fields populated. Updates: summary and read_time empty.

~/.hermes/workspace/scripts/build-digest-email.sh /tmp/digest-output.html < /tmp/digest-input.txt

Send via ops@example.com with --from "digest-bot@example.com". Label the digest as Review.

Logo cache

Sender logos cached in ~/.hermes/workspace/data/sender-logos/. Use ~/.hermes/workspace/scripts/fetch-sender-logo.sh <domain> to fetch missing ones. The build script handles base64 encoding inline.

Manual Triage = Full Pipeline

When a user asks to triage emails in a conversation (not the cron), always run the full pipeline — don't just label and archive.

In Hermes, if a personal email-triage cron job exists, trigger it with the cronjob tool after any labeling corrections. If no such job exists, run the digest steps inline yourself. Do not rely on legacy cron CLI commands from the old setup.

This ensures the digest email gets sent. Only do inline labeling for one-off corrections (relabels, unsubscribes, filter creation).

Key Rules

  1. Every email gets a label. No exceptions.
  2. Subject-specific filters. Never blanket-filter a sender that sends mixed email types.
  3. Don't filter login/auth emails. Even if the sender also sends archivable content.
  4. Unsubscribed = actually unsubscribed. Never apply it unless the action succeeded.
  5. Conservative with unsubscribe. If unsure → Newsletter or To Read, not To Unsubscribe.
  6. Archive after unsubscribing. Don't leave dead emails in inbox.
  7. Archive existing matches after creating a new filter. Filters only apply to future emails.
  8. Check for existing filters before creating duplicates: gog gmail settings filters list --account user@example.com --plain
  9. Beehiiv = browser. curl gets 403. Use agent-browser.
  10. POST for one-click. Some providers need POST, not GET. Try GET first, then POST if 404.
#!/usr/bin/env bash
# Extract the primary article URL from a newsletter email
# Usage: extract-newsletter-url.sh <message_id> [account]
# Outputs: the resolved article URL (cleaned of tracking params)
set -euo pipefail
MSG_ID="${1:?Usage: extract-newsletter-url.sh <message_id> [account]}"
ACCOUNT="${2:-user@example.com}"
# Get the email body and extract the best URL
URL=$(gog gmail get "$MSG_ID" --json --account "$ACCOUNT" 2>/dev/null | python3 -c "
import sys, json, re
data = json.load(sys.stdin)
body = data.get('body', '')
lines = body.split('\n')
# Strategy 1: 'View in browser' / 'view the post online' / 'read online' etc.
# Check current line, next line, and parenthetical URLs
for i, line in enumerate(lines):
lower = line.lower().strip()
# Pattern: 'View in browser ( URL )' or 'View in browser: URL'
if any(phrase in lower for phrase in [
'view in browser', 'view online', 'read in browser',
'view this email', 'view this post', 'read this email',
'view the post online', 'copy and paste this link',
'open in browser', 'view in your browser'
]):
# Check this line for URL (possibly in parens)
urls_in_line = re.findall(r'https?://[^\s<>\")\]]+', line)
if urls_in_line:
print(urls_in_line[0])
sys.exit(0)
# Check next few lines for URL
for j in range(i+1, min(len(lines), i+4)):
urls_next = re.findall(r'https?://[^\s<>\")\]]+', lines[j])
if urls_next:
print(urls_next[0])
sys.exit(0)
# Strategy 2: First URL that looks like article content (not images/tracking/unsubscribe)
all_urls = re.findall(r'https?://[^\s<>\")\]]+', body)
for u in all_urls:
lower_u = u.lower()
# Skip non-content URLs
if any(skip in lower_u for skip in [
'unsubscribe', 'manage', 'preference', 'tracking', 'pixel', 'beacon',
'cdn-cgi', 'media.beehiiv', '.png', '.gif', '.jpg', '.jpeg', '.svg',
'email-analytics', 'click?upn=', 'list-manage.com', 'mailchimp.com',
'passport.online/member'
]):
continue
print(u)
sys.exit(0)
# Fallback: first URL, whatever it is
if all_urls:
print(all_urls[0])
")
if [ -z "$URL" ]; then
echo ""
exit 0
fi
# If URL is a tracking redirect, follow it to get the final destination
if echo "$URL" | grep -qE '(ablink\.|click\?|redirect|trk\.|track\.)'; then
FINAL=$(curl -sI -L --max-redirs 5 --max-time 10 "$URL" 2>/dev/null | grep -i '^location:' | tail -1 | sed 's/^[Ll]ocation: *//' | tr -d '\r\n')
if [ -n "$FINAL" ]; then
URL="$FINAL"
fi
fi
# Strip common tracking params
echo "$URL" | sed -E 's/[?&](lid|utm_[a-z]+|ref|mc_[a-z]+|access_token)=[^&]*//g' | sed 's/[?&]$//' | sed 's/\?&/?/'
#!/bin/bash
# Fetch and cache sender logos from multiple sources
# Usage: fetch-sender-logo.sh <domain> [--force]
# Outputs: path to cached logo file
# Tries icon.horse and Google favicons, keeps the higher-res one
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CACHE_DIR="${WORKSPACE_DIR}/data/sender-logos"
MIN_SIZE=64 # minimum acceptable pixel dimension
domain="${1:?Usage: fetch-sender-logo.sh <domain> [--force]}"
force="${2:-}"
# Strip any email address down to domain
domain=$(echo "$domain" | sed 's/.*@//' | tr '[:upper:]' '[:lower:]')
cached="${CACHE_DIR}/${domain}.png"
if [ -f "$cached" ] && [ "$force" != "--force" ]; then
echo "$cached"
exit 0
fi
mkdir -p "$CACHE_DIR"
tmpdir=$(mktemp -d)
trap "rm -rf $tmpdir" EXIT
best_file=""
best_size=0
# Source 1: icon.horse (gets apple-touch-icons, often 180x180+)
if curl -sL -o "$tmpdir/iconhorse.png" "https://icon.horse/icon/${domain}" --max-time 8 2>/dev/null; then
if [ -s "$tmpdir/iconhorse.png" ]; then
w=$(sips -g pixelWidth "$tmpdir/iconhorse.png" 2>/dev/null | awk '/pixelWidth/{print $2}')
h=$(sips -g pixelHeight "$tmpdir/iconhorse.png" 2>/dev/null | awk '/pixelHeight/{print $2}')
dim=$(( w > h ? w : h ))
if [ "$dim" -gt "$best_size" ] 2>/dev/null; then
best_size=$dim
best_file="$tmpdir/iconhorse.png"
fi
fi
fi
# Source 2: Google favicons at max size (256)
if curl -sL -o "$tmpdir/google.png" "https://www.google.com/s2/favicons?domain=${domain}&sz=256" --max-time 8 2>/dev/null; then
if [ -s "$tmpdir/google.png" ]; then
w=$(sips -g pixelWidth "$tmpdir/google.png" 2>/dev/null | awk '/pixelWidth/{print $2}')
h=$(sips -g pixelHeight "$tmpdir/google.png" 2>/dev/null | awk '/pixelHeight/{print $2}')
dim=$(( w > h ? w : h ))
if [ "$dim" -gt "$best_size" ] 2>/dev/null; then
best_size=$dim
best_file="$tmpdir/google.png"
fi
fi
fi
if [ -z "$best_file" ] || [ "$best_size" -lt "$MIN_SIZE" ]; then
# Fallback: generate a letter avatar
letter=$(echo "$domain" | cut -c1 | tr '[:lower:]' '[:upper:]')
# Use sips to create a simple colored square with the letter
# Actually, just use the Google one even if small - better than nothing
if [ -n "$best_file" ]; then
cp "$best_file" "$cached"
echo "$cached"
exit 0
fi
echo "NONE" >&2
exit 1
fi
cp "$best_file" "$cached"
echo "$cached"
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment