Objective
Distribute the daily AI news digest as multipart HTML email to a configured recipient list via SMTP. Reads the same data_{DATE}.json as gen-message (already scored, deduped, cross-source verified) and produces:
- HTML email body —
email_{DATE}.html(also used as the email body) - Plain text fallback —
email_{DATE}.txt(multipart/alternative pairing) - Send manifest —
email_{DATE}_manifest.json(per-recipient send status)
This is the first true automated push channel in the pipeline — gen-message / gen-social only generate files for manual sharing, while gen-email actually delivers to inboxes.
Factual integrity inherited from gen-message: every item must have a source_url. Items with score ≥ 7 must additionally satisfy verified == true. Drop items lacking a credible source.
Configuration
| Variable | Required | Default | Description |
|---|---|---|---|
EMAIL_ENABLED |
— | false |
Master switch — when unset/false, the step is silently skipped |
EMAIL_RECIPIENTS |
✓ | — | Comma-separated list, e.g. alice@x.com,bob@y.com |
EMAIL_RECIPIENTS_FILE |
— | .claude/recipients.json |
Optional JSON file — overrides env list when present |
EMAIL_SMTP_HOST |
✓ | — | SMTP server, e.g. smtp.gmail.com, smtp.qq.com |
EMAIL_SMTP_PORT |
— | 587 |
Port (587=STARTTLS, 465=SSL, 25=plain) |
EMAIL_SMTP_TLS |
— | starttls |
starttls / ssl / none |
EMAIL_SMTP_USER |
✓ | — | SMTP auth username |
EMAIL_SMTP_PASSWORD |
✓ | — | SMTP password (Gmail: use App Password, not account password) |
EMAIL_FROM |
— | EMAIL_SMTP_USER |
Display sender, e.g. MorningAI <noreply@x.com> |
EMAIL_REPLY_TO |
— | — | Reply-To header |
EMAIL_SUBJECT_TEMPLATE |
— | MorningAI {date} · {n} updates |
Subject template, supports {date} {n} {lang} |
EMAIL_LANG |
— | follows --lang |
Email language: zh / en / ja |
EMAIL_MIN_SCORE |
— | 5 |
Min importance score to include |
EMAIL_MAX_ITEMS |
— | 10 |
Max items in the digest |
EMAIL_ATTACH_IMAGE |
— | true |
Attach message_{DATE}.png if it exists |
EMAIL_RATE_LIMIT_DELAY |
— | 1 |
Seconds to sleep between recipients (anti-throttling) |
EMAIL_LIST_UNSUBSCRIBE |
— | — | Unsubscribe target, e.g. mailto:admin@x.com?subject=Unsubscribe |
EMAIL_DRY_RUN |
— | false |
If true, generate email_*.html/email_*.txt locally and do not send |
EMAIL_KOL_ENABLED |
— | true |
Render a separate "KOL Voices" section after the main items. Set false to suppress entirely |
EMAIL_KOL_MIN_SCORE |
— | 4 |
Min importance for KOL items (independent of EMAIL_MIN_SCORE because KOL voices are scored 4-7 by design) |
EMAIL_KOL_MAX_ITEMS |
— | 5 |
Max KOL items to include in the section |
EMAIL_KOL_SHOW_EMPTY |
— | true |
When no KOL items qualify, still render the section header with a "today was quiet" empty-state line. Set false to omit the section on quiet days |
Recipients JSON format (EMAIL_RECIPIENTS_FILE)
[
{"email": "alice@example.com", "name": "Alice", "lang": "zh", "min_score": 6, "active": true},
{"email": "bob@example.com"},
{"email": "paused@example.com", "active": false}
]
Per-recipient lang and min_score override the global defaults. Inactive entries are skipped.
Content Selection Rules
Same as gen-message (see skills/gen-message/SKILL.md for full spec):
- Load
data_{DATE}.jsonfrom CWD. - Filter:
importance >= min_score(per-recipient or global),source_urlpresent,verified == truefor score ≥ 7. - Skip items with
is_kol_voice == true— they render in the dedicated KOL section, not mixed into the main feed. - Apply category balance:
product≤ 4,model≤ 3,benchmark≤ 2,financing≤ 2; overflow filled by score. - Sort by
importancedesc, cap atEMAIL_MAX_ITEMS.
KOL Voices Section
A separate block rendered after the main items, mirroring the ## KOL Voices section in report_{DATE}.md and the dedicated KOL channel in gen-social. Independent commentary deserves its own visual lane — without it, KOL takes either get suppressed by the 7+ verification gate (KOL voices are scored 4-7 by design) or appear interleaved with vendor announcements.
KOL filter rules:
- Only items with
is_kol_voice == true. importance >= EMAIL_KOL_MIN_SCORE(default4, lower than the mainEMAIL_MIN_SCORE).- Must have
source_url. - No 7+ verification gate — matches
gen-infographic's KOL handling: KOL voices are commentary, not vendor announcements that need cross-source verification. - Sorted by
importancedesc, capped atEMAIL_KOL_MAX_ITEMS.
When zero KOL items qualify, the section is still rendered with a "today's KOL voices were quiet" line if EMAIL_KOL_SHOW_EMPTY=true (the default), so subscribers see the section is alive even on quiet days. Set EMAIL_KOL_SHOW_EMPTY=false to omit the section entirely on quiet days, or EMAIL_KOL_ENABLED=false to suppress it permanently.
Workflow Summary
The send_email.py script handles all of the following automatically:
- Read config, exit silently if
EMAIL_ENABLED != true. - Validate required config (
EMAIL_SMTP_HOST/EMAIL_SMTP_USER/EMAIL_SMTP_PASSWORD/ recipients) — exit with clear error if missing. - Load
data_{DATE}.jsonand apply selection rules. - Load recipients from
EMAIL_RECIPIENTS_FILE(if exists) orEMAIL_RECIPIENTS. - Render preview using global
EMAIL_LANG→ writeemail_{DATE}.htmlandemail_{DATE}.txt. - If
EMAIL_DRY_RUN=true, exit after writing previews (no SMTP traffic). - Otherwise send to each recipient (re-render in their preferred language), sleeping
EMAIL_RATE_LIMIT_DELAYseconds between sends. - Write
email_{DATE}_manifest.jsonwith per-recipientstatus(sent/failed) anderrortext.
Invocation
The orchestrator (skills/morning-ai/SKILL.md Step 7) runs:
python3 skills/gen-email/scripts/send_email.py --date {YYYY-MM-DD}
Optional flags:
--date YYYY-MM-DD— override the date (default: today UTC+8)--data PATH— override the data file path (default:data_{DATE}.jsonin CWD)--lang zh|en|ja— overrideEMAIL_LANG--dry-run— equivalent toEMAIL_DRY_RUN=true
Compliance & Anti-Spam
- List-Unsubscribe header (RFC 2369) is automatically added when
EMAIL_LIST_UNSUBSCRIBEis set. Major clients (Gmail, Outlook, Apple Mail) display a one-click unsubscribe button. - Plain-text fallback is always included (multipart/alternative). Improves deliverability for clients that strip HTML.
- Rate limiting (
EMAIL_RATE_LIMIT_DELAY) prevents tripping SMTP provider throttles (Gmail caps free accounts at ~100 sends/day, with short bursts blocked). - Per-recipient send (no BCC) — protects subscriber privacy, allows per-recipient personalization (language), and avoids "looks like spam blast" heuristics.
When a recipient asks to unsubscribe, the admin manually removes them from EMAIL_RECIPIENTS or recipients.json. Self-service unsubscribe requires a hosted backend (out of scope for this skill).
Common SMTP Configurations
See docs/email-setup.md for full details. Quick reference:
| Provider | Host | Port | TLS | Notes |
|---|---|---|---|---|
| Gmail | smtp.gmail.com |
587 | STARTTLS | Requires App Password (turn on 2FA → generate App Password) |
| QQ Mail | smtp.qq.com |
465 | SSL | Use authorization code, not login password |
| Outlook | smtp.office365.com |
587 | STARTTLS | Account password or App Password |
| Alibaba Cloud Enterprise Mail | smtp.qiye.aliyun.com |
465 | SSL | Account password |
Example: Manifest Output
{
"date": "2026-04-20",
"subject": "MorningAI 2026-04-20 · 8 updates",
"total": 3,
"succeeded": 2,
"failed": 1,
"results": [
{"email": "alice@example.com", "status": "sent", "message_id": "<...@morning-ai>"},
{"email": "bob@example.com", "status": "sent", "message_id": "<...@morning-ai>"},
{"email": "carol@example.com", "status": "failed", "error": "smtp error: 550 mailbox unavailable"}
]
}
The manifest enables retry logic in future runs (skip already-sent recipients) and makes failures visible without scraping logs.