Readme
Last updated: 5/8/2026
Personal Newsletter — How to Use and Write
This folder is where the content of every newsletter issue lives. The CLI tooling lives at ~/Projects/personal-newsletter/ and reads from this folder via a symlink (~/Projects/personal-newsletter/issues → this folder).
Two-folder split
| Concern | Lives at | Why |
|---|---|---|
| Issue drafts (markdown) | ~/notes/areas/personal-brand/personal-newsletter/ (here) | Content. Synced via Syncthing. Searchable in Obsidian. Linkable from /now, dailies, worklogs. |
| CLI tooling (Python) | ~/Projects/personal-newsletter/ | Code. Versioned in its own git repo. Runs newsletter command. |
| Sent archive (immutable) | ~/Projects/personal-newsletter/sent/ | Sent issue manifests with Resend message IDs + recipient snapshots. Stays in project repo for clean separation. |
| Subscriber state | ~/notes/areas/relationships/relationship-data/{slug}.md frontmatter | Per-person fields: email, newsletter_subscribed, newsletter_frequency, newsletter_confirmed_at, newsletter_unsubscribed_at, newsletter_source. |
Writing a new issue
- Create a new file in this folder named
YYYY-MM-DD-issue-N.md(or whatever's meaningful). - Use frontmatter for the email subject:
yaml---subject: "What I'm up to (a note from Matt)"---
- Write your content as plain markdown. Standard markdown features supported:
- Headings (
#,##,###) - Lists (bulleted, numbered)
- Inline links (
[text](url)) - Bold (
**word**), italic (_word_), inline code (`code`) - Block quotes (
> ...) - Horizontal rules (
---) - Tables, strikethrough
- Headings (
- Use
{{first_name}}anywhere in the body — it's replaced per-recipient at send time. - Keep it short (200–400 words). Five short sections beats one long one.
- The first H1 (
# heading) is treated as the in-body title and stripped from email body if it equals the subject.
Voice guidelines (per resources/matts-writing-style-profile.md)
- Direct, warm, unpolished. Short paragraphs. Contractions always.
- No em-dashes. No "delve", "leverage", "synergy", "I'm excited to share", "I hope this email finds you well."
- "I think", "I believe", "My conjecture is..." for genuine opinions. State emotions plainly.
- Closing:
Best,\n-Matt - Greeting:
Hey {{first_name}},
Recommended section structure
Per the sustainability research — 1+ year personal-newsletter operators converge on fixed headers:
- What I'm working on — current focus, projects, work
- What I'm building / reading / curious about — content + interests
- Where I am — physical location, travel
- What I'm thinking about — open questions, asks
- How can I help? — invite reciprocity (Matt's signature CTA)
You don't have to use these exact headers, but the principle is: fill in known sections, don't compose from a blank page.
Sending an issue (CLI workflow)
All commands run from ~/Projects/personal-newsletter/. The venv is at .venv/.
0. First-time / sanity check
cd ~/Projects/personal-newsletter.venv/bin/python -m newsletter doctorShould show all green. Verifies env vars (RESEND_API_KEY, RESEND_FROM, etc.), Python deps, vault path, Resend domain.
1. Refresh recipient candidates from your relationship-data
.venv/bin/python -m newsletter scan-relationships --strength 1-5 --min-name-match 2Scans ~/notes/areas/relationships/relationship-data/*/ for emails. Output: recipients-candidates.csv at the project root. Includes a column n_emails_for_slug flagging people with multiple candidate addresses (you pick one when curating).
To find people you know but for whom no email is captured (for the contact-asker workflow):
.venv/bin/python -m newsletter find-no-email --limit 30Output: no-email-candidates.csv sorted by signal strength (more files / messages / deep-research = stronger relationship).
2. Curate the recipient list
Open recipients-candidates.csv and:
- Set
includetoyon rows you want to send to. - For people with multiple candidate emails (
n_emails_for_slug > 1), mark exactly ONE. - Save as
recipients-active.csv.
Validate:
.venv/bin/python -m newsletter validate-recipients recipients-active.csvErrors on: invalid email syntax, duplicate emails, multiple emails marked for the same person.
3. Render preview
.venv/bin/python -m newsletter render --plaintext --check issues/YYYY-MM-DD-issue-N.mdWrites ~/Projects/personal-newsletter/build/{name}.html and .txt. Open the HTML in a browser to spot-check.
4. Test-send to yourself
.venv/bin/python -m newsletter test-send issues/YYYY-MM-DD-issue-N.mdSends a single message to MATT_TEST_EMAIL (from .env, defaults to matt@matthandzel.com). No recipient list involved. Subject can be overridden with --subject "[TEST] ...".
5. Run mail-tester.com (recommended for first issue or after layout changes)
Get a fresh test address from https://www.mail-tester.com/, then:
MATT_TEST_EMAIL=test-XXXX@srv1.mail-tester.com .venv/bin/python -m newsletter test-send issues/YYYY-MM-DD-issue-N.mdVisit https://www.mail-tester.com/test-XXXX to see the score. Aim for ≥9/10.
6. Dry-run the real send
.venv/bin/python -m newsletter send issues/YYYY-MM-DD-issue-N.md --recipients recipients-active.csv --dry-runPrints a table of recipients + the subject. No API calls.
7. Real send
.venv/bin/python -m newsletter send issues/YYYY-MM-DD-issue-N.md --recipients recipients-active.csvSends individual personalized messages via Resend's REST API. Each message has:
From: Matt Handzel <newsletter@matthandzel.com>Reply-To: newsletter@matthandzel.com(forwards to your main inbox)List-Unsubscribe: <mailto:newsletter+unsubscribe@matthandzel.com>List-Unsubscribe-Post: List-Unsubscribe=One-Click- Multipart: HTML + plain-text alternative
- First-name greeting personalized per recipient
After: a manifest is archived to ~/Projects/personal-newsletter/sent/{slug}-{ISO8601}/ with manifest.json (Resend message IDs, recipients), body.html, body.txt, source.md.
To stage a warm-up ramp (issue 1 ~25 → issue 2 ~60 → issue 3 ~150), use --limit N.
Cadence (planned for v1)
Two frequencies will be supported once the website preferences flow ships:
quarterly— default. Issues sent end of March / June / September / December.yearly— only the December issue (year in review).
newsletter send --cadence quarterly will only include subscribers whose newsletter_frequency is quarterly or unset. --cadence yearly is the December annual issue and goes to everyone (quarterly + yearly + unset).
Until that ships, every send goes to every confirmed recipient regardless of preference.
File layout (cheat sheet)
~/notes/areas/personal-brand/personal-newsletter/ <-- you are here
README.md <-- this file
YYYY-MM-DD-issue-N.md <-- issue drafts
_smoke-test.md <-- throwaway test draft
~/Projects/personal-newsletter/ <-- CLI lives here
.env <-- secrets (gitignored)
pyproject.toml <-- Python package
src/newsletter/ <-- source
tests/ <-- pytest suite
issues/ <-- SYMLINK to vault folder
sent/{date-slug}/ <-- archived sends
build/ <-- render output (gitignored)
recipients-candidates.csv <-- output of scan-relationships
recipients-active.csv <-- you curate this
no-email-candidates.csv <-- output of find-no-email
PLAN.md <-- task decomposition
ENGINE-LOG.md <-- audit trail
STATE.md <-- handoff state dump
~/notes/projects/personal-newsletter/ <-- vault project doc
personal-newsletter.md <-- description
_worklog.md <-- project worklog
SPEC.md <-- full spec
research/ <-- research files
Anti-goals (per SPEC.md)
- No marketing CSS / SaaS-launch templates. Personal email aesthetic.
- No URL shorteners (
bit.ly,t.co). - No mass greetings ("Hey there!", "Friend!"). First names only.
- No apologies for not-having-sent-earlier in a follow-up issue. Per sustainability research, this is the dominant abandonment trigger — just send.
- Skip a quarter without explanation if life happens. The next issue does not catch up, justify, or apologize. It just sends.