Add a per-feed senderInTitle flag (domain FeedState.senderInTitle ↔
FeedConfig.sender_in_title). When set, the feed generator prefixes each
entry title with [Sender] (display name, falling back to the address).
Exposed as an admin edit-form checkbox and across the REST API
create/update/response schemas.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a "Public page" link next to the Rendered/Raw toggle in the admin
email view, opening the standalone /entries/:feedId/:entryId render.
Centralize the entry route shape in a pure entryPath() builder, used by
both the admin link and the RSS/Atom/JSON feed generator.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Separate the two feed identities so the public read URL never reveals the
inbound address and vice-versa:
- FeedId becomes an opaque high-entropy token (read id + KV key); MailboxId
(noun.noun.NN) owns the inbound address and the untrusted-input boundary
via MailboxId.parse. They map only through the inbound:<mailbox> secondary
index, resolved solely at reception.
- inbound index lifecycle is owned by FeedRepository: written by save/saveConfig,
dropped by removeFromList(Bulk) — symmetric, never mirrored by hand (removes the
manual delete in feed-service + the cron loop, and a silent empty-catch).
- Feed.mailboxId exposes a MailboxId VO (symmetry with Feed.id); the
mailbox@domain shape lives on MailboxId.emailAddress(domain).
- Distinguish mailbox_unknown (no feed claims the address) from feed_not_found
(dangling index) for observability; both forwardable, both 404.
- Drop the redundant EmailParser.extractMailbox pass-through so MailboxId.parse
is the single parse boundary.
Docs (README/INSTALL/CLAUDE.md/landing) and tests updated; 439 tests green,
tsc clean, build dry-run OK.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Batch of four reader-facing improvements (TODO "Compat lecteurs + dedup"):
- JSON Feed at /json/:feedId (feed lib .json1()); all formats cross-link
- OPML export at /admin/opml (admin-protected; the registry lists every
feed URL, so it must not be public)
- Conditional GET on /rss + /atom: strong ETag + Last-Modified, 304 on
If-None-Match/If-Modified-Since, validators shared via http-cache.ts
- Duplicate-send dedup in ingestion: match by Message-ID, fall back to a
SHA-256 of normalized subject+content; a duplicate is a no-op and bumps
the new emails_deduplicated counter (status page + /api/v1/stats)
429 tests green, tsc clean, build dry-run OK. Docs (README/CLAUDE/TODO +
landing cards) updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Close the five open P1·S items from TODO.md:
- X-Robots-Tag: noindex on rss/atom/entries/files + a /robots.txt
- absolutize relative content URLs against the sender's site
- promote lazy-loaded images (data-src → src, strip loading="lazy")
- strip XML-illegal control chars from generated feeds (keep emoji)
- plain-text feed <title> (strip HTML, decode entities)
Sender-base derivation lives on the EmailAddress value object
(siteBaseUrl) instead of a misplaced favicon helper. Bump to 0.2.1
and document the changes in README + CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inline images (referenced by src="cid:…") are now classified at ingest and
kept out of the downloadable attachment lists, RSS/Atom enclosures, and the
API — while still stored in R2 and cleaned up with the email. Fixes the admin
email preview, which injected raw HTML into the data: iframe so cid refs never
resolved; it now rewrites them to absolute /files URLs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>