Commit Graph
100 Commits
Author SHA1 Message Date
Julien HerrandClaude Opus 4.7 2c450817df feat(email): forward non-feed mail to FALLBACK_FORWARD_ADDRESS
Lets you point a domain's catch-all at the worker without losing personal
mail: inbound mail that isn't a feed (invalid_address / feed_not_found) is
forwarded to an optional verified destination instead of being dropped.
Expired feeds and blocked senders are still dropped so newsletters never
leak to the fallback inbox. Unset env keeps the original drop-and-log path.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 17:14:04 +02:00
Julien HerrandClaude Opus 4.7 6cb036fe2c docs(todo): catalog feature gaps with origin refs and priority/size tags
Survey of kill-the-newsletter issues/PRs, competitors, RSS readers, and a
code audit. Each idea carries an origin reference (so we can notify the
requester on ship) and a Pn·Size badge (user value × implementation effort).
Adds the FALLBACK_FORWARD_ADDRESS catch-all fallback-forwarding idea.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 17:11:11 +02:00
Julien HerrandClaude Opus 4.7 b002f8ad43 refactor: extract inline view CSS into dedicated stylesheets
Move the hardcoded <style> blocks from the single-email view and the admin
email preview iframe into src/styles/*.css so they benefit from Prettier,
linting, and syntax highlighting like the rest of the design system.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 14:54:33 +02:00
Julien HerrandClaude Opus 4.7 5137637181 feat(attachments): render inline cid images in place, not as attachments
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 <[email protected]>
2026-05-24 14:39:59 +02:00
Julien HerrandClaude Opus 4.7 be45e70571 docs(landing): add auto-expiring feeds feature card
Surface the feed TTL / auto-deletion capability on the landing page,
framed as a disposable temporary-mailbox use case.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 14:13:45 +02:00
Julien HerrandClaude Opus 4.7 06c436c36a refactor: separate Feed domain state from persistence DTO
Move four DDD tensions on the Feed aggregate to ground:

- #1 The aggregate now holds a domain FeedState (camelCase) instead of the
  snake_case FeedConfig DTO; infrastructure/feed-mapper.ts owns the
  FeedState<->FeedConfig/FeedListItem translation as the sole snake_case site
  outside the HTTP edge.
- #3 Replace the edit() recomputeExpiry control flag with a Lifetime VO:
  passing a lifetime recomputes expiry, omitting it preserves the current one
  (the dashboard quick-edit path).
- #4 Domain events carry their own feedId; dispatchFeedEvents centralizes the
  drain+dispatch in the application layer (no more manual pullEvents at call
  sites), keeping infra->application dependency direction intact.
- #6 Rename FeedId.fromTrusted to FeedId.unchecked to make the absence of
  revalidation explicit.

Adds Lifetime + feed-mapper round-trip tests. 353 tests green, tsc clean,
wrangler dry-run OK. Docs (CLAUDE.md) synced.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 14:10:04 +02:00
Julien HerrandClaude Opus 4.7 d68a24867d docs(claude): sync layering rules with the aggregate refactor
The aggregate no longer exposes raw config/metadata (intention-revealing
accessors + snapshots), feeds:list is maintained by the repository from
feed.summary(), edit() is the single config mutation, and FeedId now flows
through the application and infrastructure layers.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:45:30 +02:00
Julien HerrandClaude Opus 4.7 ad196f1761 refactor: tighten DDD boundaries on the Feed aggregate
Address five modeling tensions in one pass:

- Encapsulation: the Feed aggregate no longer exposes raw config/metadata
  (a shallow Readonly still leaked mutable arrays). It now offers
  intention-revealing accessors that return copies, plus
  toConfigSnapshot/toMetadataSnapshot for the repository and summary() for
  the global registry.
- feeds:list consistency: FeedRepository.save/saveConfig upsert the registry
  entry from feed.summary(), so services no longer mirror title/description/
  expiry by hand (the old add/updateInList footgun is gone).
- domain/feed.ts: drop the dead applySenderPolicy, internalise resolveExpiresAt
  and trimToByteBudget into the aggregate; feed.ts keeps only the shared
  isExpired predicate used by the read-model routes.
- Single edit path: remove editDetails; edit(patch, deps) is the sole config
  mutation, with a systematic expired guard. Renaming an expired feed now 403s.
- FeedId flows through the application and infrastructure signatures;
  fromTrusted/parse happen once at the edge, .value only at the serialisation
  boundaries (urls, feed-generator, feed-keys, logs, JSON).

347 tests green, tsc clean, Worker bundle builds.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:45:13 +02:00
Julien HerrandClaude Opus 4.7 b3d42f6c50 refactor: introduce domain events for feed side effects (Track E — point 5)
Light "collect + dispatch" variant: the Feed aggregate records FeedEvents
(FeedCreated, EmailIngested) on the mutations that have consequences, exposed via
pullEvents(). A new application dispatcher (feed-events.applyFeedEvents) maps
those events to their side effects — counters (awaited) plus WebSub pings and
favicon fetches handed to a BackgroundScheduler. This removes the inline,
scattered side effects from the ingest hot path (email-processor) and from
createFeedRecord; the aggregate is now the source of truth for "what happened".

Side effects with no aggregate mutation (rejected email, feed deletion bypassing
the aggregate, bulk admin ops, the cron, unsubscribes-sent) stay imperative by
design — there is no aggregate event for them to ride on.

BackgroundScheduler type moved to infrastructure/worker.ts (shared). CLAUDE.md
updated. 355 tests pass (+4 event tests); tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:12:42 +02:00
Julien HerrandClaude Opus 4.7 46af982c40 refactor: invert application↔routes boundary (Track B — points 3, 6a)
- Point 3: move the feed/email storage-cleanup helpers (purgeFeedKeysStep,
  collectUnsubscribeUrls, purgeExpiredFeeds, deleteKeysWithConcurrency,
  deleteAttachmentsForEmails) out of routes/admin/helpers.ts into
  src/application/feed-cleanup.ts, so the application layer no longer imports
  from routes/. deleteFeedRecord no longer takes a Hono Context: it accepts a
  BackgroundScheduler ((task) => void) and the HTTP edge passes
  (p) => waitUntilSafe(c, p). Application/domain are now Hono-Context-free.
- Point 6a: rename the misleadingly-named Feed.rename → Feed.editDetails (it
  edits title + description), and feed-service.renameFeed → editFeedDetails.

CLAUDE.md source layout updated. 351 tests pass; tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 10:05:21 +02:00
Julien HerrandClaude Opus 4.7 f823a5f222 refactor: move KV repositories to infrastructure (Track P — points 2, 6c)
Make the domain stop depending on infrastructure ("imports point inward").

- Point 2: relocate the four KV adapters (FeedRepository, IconRepository,
  WebSubSubscriptionRepository, CountersRepository) from domain/ to
  infrastructure/, where the logger import is legitimate. The domain now keeps
  only the pure key schema (feed-keys.ts), the Feed aggregate and value objects;
  it imports nothing outward. Deliberately no hand-rolled 24-method port
  interface (YAGNI without DI) — relocation alone fixes the direction.
- Point 6c: EmailParser.extractFeedId now returns a validated FeedId value
  object instead of a raw string, so the most untrusted input (an inbound
  recipient address) is guarded at the parse boundary and no longer round-trips
  through FeedId.fromTrusted in the ingest path.

All import paths updated; CLAUDE.md source layout/KV-schema notes updated.
351 tests pass; tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 10:02:23 +02:00
Julien HerrandClaude Opus 4.7 23dd0a0c96 refactor(domain): purify the Feed aggregate (Track D — points 1, 4, 6b)
Remove the infrastructure Env leak and ambient time from the domain core, and
model the sender policy as a value object.

- Point 1: Feed.create/edit no longer receive Env. The application layer resolves
  the effective lifetime (parsing FEED_TTL_HOURS and applying the server override)
  via feed-service.resolveTtlHours and hands the domain a plain ttlHours.
  resolveExpiresAt(ttlHours, now) is now pure.
- Point 4: introduce a Clock port (systemClock default), injected at
  create/reconstitute. The aggregate uses clock.now() instead of Date.now().
  The isExpired edge helper keeps its Date.now() default for routes.
- Point 6b: extract SenderPolicy value object built once from the lists
  (decide(senders)) instead of re-parsing per sender; applySenderPolicy is now a
  thin wrapper over it.

Coverage moved with the logic: the FEED_TTL_HOURS override is now pinned by
feed-service.test.ts; aggregate tests use an injected fixed clock.

351 tests pass; tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 09:55:55 +02:00
Julien HerrandClaude Opus 4.7 e324571122 docs(claude): document the DDD layering, Feed aggregate and repo split
Refresh the source layout for domain/application/infrastructure, replace
the single-repository rule with the four-repository split + feed-keys,
and add domain rules: the Feed aggregate is the only writer of config +
the email index, and FeedId circulates through domain and repository.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:48:13 +02:00
Julien HerrandClaude Opus 4.7 7bf0f71f86 refactor: split src into domain / application / infrastructure layers
Replace the history-driven lib/ + utils/ split with DDD layers:
- domain/: aggregate, repositories, value objects, pure parsers/format
- application/: feed-service, email-processor, feed-fetcher, stats
- infrastructure/: logging, auth, KV/R2 adapters, HTTP, framework glue

Pure file relocation; imports updated mechanically. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:46:56 +02:00
Julien HerrandClaude Opus 4.7 ab1c15e69a refactor(domain): make FeedId circulate through the domain and repository
FeedId is now the type of Feed.id and of every single-feed method on
FeedRepository; callers wrap raw strings via FeedId.fromTrusted at the
repository boundary. String-medium operations (URLs, logs, JSON,
list registry, email keys) stay string. Drop the redundant
generateFeedId wrapper in favour of FeedId.generate.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:44:24 +02:00
Julien HerrandClaude Opus 4.7 05388b45c8 refactor(domain): split updateFeedRecord into renameFeed and editFeed
The inPlace boolean hid two distinct intentions. Replace it with two
intention-revealing operations backed by Feed.rename (presentational,
never touches expiry) and Feed.edit (full edit, recomputes expiry,
rejects expired). Add FeedRepository.saveConfig so these config-only
edits don't re-write (and risk clobbering) the email index.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:35:07 +02:00
Julien HerrandClaude Opus 4.7 c45f6677fe refactor(domain): introduce the Feed aggregate as the write-path API
Add a Feed aggregate class owning config + the email index, with create,
ingest, removeEmails, isExpired and accepts delegating to the existing
pure invariant functions. FeedRepository gains load/save/saveMetadata
that reconstitute and persist the aggregate.

All write paths now go through it: createFeedRecord (Feed.create),
email ingestion (feed.ingest), and every email deletion in the admin UI
and REST API (feed.removeEmails) — no route mutates metadata.emails
directly anymore. KV key strings unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:33:14 +02:00
Julien HerrandClaude Opus 4.7 a31ff42f59 refactor(domain): split Icon/WebSub/Counters out of FeedRepository
FeedRepository no longer owns favicons, WebSub subscriber lists or the
monitoring counters singleton. Each concern gets its own repository
(IconRepository, WebSubSubscriptionRepository, CountersRepository),
sharing the key schema via feed-keys. KV key strings are unchanged;
counters increment policy stays in utils/stats.ts.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:27:33 +02:00
Julien HerrandClaude Opus 4.7 b347f2f625 refactor(domain): extract the KV key schema into feed-keys.ts
Move the feed:/icon:/websub: key builders out of FeedRepository into a
pure feed-keys module so the wire format lives in one place, shared by
the repositories to come. Strings are byte-identical; behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:24:43 +02:00
Julien HerrandClaude Opus 4.7 49f69ff19e docs(claude): document the domain layer and FeedRepository KV access rule
Reflect the refactor: add the src/domain/ tree (feed-repository, feed, value
objects), drop the deleted storage.ts, and update the KV-schema note to point at
FeedRepository as the single key-access layer. Correct the websub key shape and
add the icon: key.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:06:48 +02:00
Julien HerrandClaude Opus 4.7 c65aabe7f4 refactor(domain): add FeedId, EmailAddress and Domain value objects
Encapsulate the email/domain/feed-id parsing that was scattered as ad-hoc
regexes and split("@") calls into three small immutable value objects under
src/domain/value-objects/. EmailParser.extractFeedId and generateFeedId now
delegate to FeedId; the sender policy, favicon domain extraction and the admin
SenderField parse through EmailAddress/Domain.

Left as-is on purpose: forwardemail's multi-address free-text extraction and the
admin allow/block list normaliser, which operate on mixed email-or-domain input
that the single-address value objects would reject.

Behaviour-preserving; adds unit tests for each value object.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:05:46 +02:00
Julien HerrandClaude Opus 4.7 8f036cf223 refactor(ingest): return a domain result from processEmail, map HTTP at the edge
processEmail/validateEmail now return an IngestResult discriminated union
({ ok } | { ok: false; reason }) instead of an HTTP Response. The status mapping
moves to the edge (ingestResultToResponse in forwardemail.ts), and the Cloudflare
email handler now logs the rejection reason instead of silently discarding it.

The ingestion core is transport-agnostic. End-to-end status mapping stays covered
by inbound.test.ts (now incl. 410 expired); email-processor.test asserts on the
domain result directly.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:02:05 +02:00
Julien HerrandClaude Opus 4.7 6b51173722 refactor(domain): consolidate Feed aggregate invariants in domain/feed.ts
Gather the feed's scattered business rules — expiry, sender allow/block policy,
and the email byte-size budget — into one framework-agnostic module. Expiry was
duplicated across feed-service, email-processor and the rss/atom/entries routes;
the sender policy and trim loop lived inline in email-processor. Each now calls
a single function (isExpired, applySenderPolicy, trimToByteBudget,
resolveExpiresAt). Drops the now-unused MAX_METADATA_EMAILS constant.

Behaviour-preserving; adds feed.test.ts covering every invariant.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:59:15 +02:00
Julien HerrandClaude Opus 4.7 2b3f00f7e3 refactor(domain): introduce FeedRepository as the single KV access layer
Centralise the KV key schema and all get/put access behind a FeedRepository
class under src/domain/. Every feed/email/list/icon/websub/counter key was
previously inlined across ~12 modules with two divergent storeEmail and
addFeedToList implementations; the dead src/utils/storage.ts write path is
removed and the email key convention unified on feed:<id>:<ts>.

Behaviour-preserving: existing tests pass unchanged in logic, plus a new
feed-repository.test.ts covering CRUD, key builders, list ops and counters.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:56:44 +02:00
Julien HerrandClaude Opus 4.7 a0eaebe749 docs(landing): add a REST API & OpenAPI feature card
Surface the new versioned REST API on the marketing landing, linking to the
live Scalar reference on the demo.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:16:54 +02:00
Julien HerrandClaude Opus 4.7 c2a0a68058 refactor(api): remove the deprecated /api/stats endpoint
The only consumer (the marketing landing) now uses /api/v1/stats, so drop
the legacy /api/stats route and its handler. Delete src/routes/stats.ts and
its test; repoint the index CORS test at /api/v1/stats.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:15:08 +02:00
Julien HerrandClaude Opus 4.7 daa93d8093 feat(api): make /api/v1/stats public and point the landing at it
Unify the monitoring stats on the versioned API: /api/v1/stats is now public
(no auth) and CORS-enabled, mirroring the legacy /api/stats. The marketing
landing (docs/index.html) now fetches /api/v1/stats; /api/stats is kept as a
deprecated alias for existing monitors. Feed/email routes remain token-gated.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:12:43 +02:00
Julien HerrandClaude Opus 4.7 45d2a14a12 feat(api): add versioned REST API with OpenAPI 3.1 spec
Expose /api/v1/* for feed and email management (feeds CRUD, email
list/get/delete, stats) so the service can be automated without scraping
the admin UI. Built on @hono/zod-openapi; the OpenAPI 3.1 spec is served at
/api/openapi.json with a Scalar reference at /api/docs.

Auth is token-based (Authorization: Bearer <ADMIN_PASSWORD>) plus the
existing reverse-proxy headers — no cookie, no CSRF. Extracted the auth
primitives into src/lib/auth.ts and the feed create/update/delete
orchestration into src/lib/feed-service.ts so the admin UI and the REST API
share a single source of truth.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:01:15 +02:00
Julien HerrandClaude Opus 4.7 7f5b913576 docs: add SECURITY.md and CONTRIBUTING.md
Add a security policy with private reporting channels and project-specific
scope, plus a contributor guide covering dev setup, testing, and commit
conventions. Drop the stale AGENTS.md reference from CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 22:18:57 +02:00
Julien HerrandClaude Opus 4.7 db31e33a8b docs: extract install/deploy/config guide into INSTALL.md
Slim README down to project overview (why, features, architecture,
security) with a short Installation quick-start that links to the new
INSTALL.md. Repoint setup.sh references and CLAUDE.md maintenance list.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 21:49:19 +02:00
Julien HerrandClaude Opus 4.7 8ffa4ad5e7 docs(license): add Julien Herr copyright alongside original author
Cumulative MIT copyright reflecting the substantial rewrite while
preserving the original author's notice as required by the license.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 21:49:13 +02:00
Julien HerrandClaude Opus 4.7 7f4afa3ec8 feat(admin): add status page link to dashboard header
Add a "Status" link in the admin header pointing to the public status page
(/), mirroring the existing "Go to admin" link on that page. Add a gap to
.header-actions so the new link and Logout button are spaced apart.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 21:43:24 +02:00
Julien HerrandClaude Opus 4.7 3368b0d1d2 feat(admin): collapse create-feed form into accordion
Wrap the "Create New Feed" form in a native <details> accordion, collapsed
by default and auto-opened when no feeds exist. After creating a feed,
redirect to the "Your Feeds" anchor so the new feed is immediately visible.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 21:41:25 +02:00
Julien HerrandClaude Opus 4.7 5fc91a0be4 refactor(html-processor): isolate cid rewrite from sanitization
Keep sanitizeElement single-purpose and run the cid: rewrite as a
separate guarded pass over [src] elements. Use a type-only import for
AttachmentData.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 18:47:20 +02:00
Julien HerrandClaude Opus 4.7 debbfc623e fix(attachments): render inline cid: images in emails and feeds
Capture each attachment's Content-ID at ingestion (postal-mime and
mailparser paths) and rewrite cid: image refs to the stored /files URL
in processEmailContent, shared by the entry view and RSS/Atom feeds.
Bodyless HTML fragments are now serialized so sanitization and the cid
rewrite apply to them too.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 18:42:04 +02:00
Julien HerrandClaude Opus 4.7 6cd2d425a2 feat(attachments): list downloadable attachments on admin email detail page
The admin email detail view loaded the full email but never rendered its
attachments, so there was no way to download them from the admin UI (only
the public entry view and the feed enclosure exposed them).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 18:11:29 +02:00
Julien HerrandClaude Opus 4.7 9141cf89bd fix(attachments): purge R2 attachments on no-JS bulk email delete
The form-based bulk-delete fallback removed KV entries but left R2
attachments orphaned. Extract a shared deleteAttachmentsForEmails helper
and use it across single, JSON bulk, and form bulk delete paths.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:57:31 +02:00
Julien HerrandClaude Opus 4.7 ddde0e26a2 docs(readme): add Continuous deployment section with CI secrets and token permissions
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:51:50 +02:00
Julien HerrandClaude Opus 4.7 20c9bca34a docs(readme): note R2 permission needed for scoped deploy tokens
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:50:04 +02:00
Julien HerrandClaude Opus 4.7 2de09b2a5d refactor(home): dedupe byte formatting in storage cards
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:39:55 +02:00
Julien HerrandClaude Opus 4.7 f150d40c45 feat(attachments): R2 toggle, storage metrics, and demo R2 config
Add an ATTACHMENTS_ENABLED switch (default on when R2 is bound) via a
central getAttachmentBucket helper, surface R2 + estimated KV usage
against the free tier on the status page and /api/stats (refreshed by the
hourly cron), let setup.sh create and wire the R2 bucket, and bind the
demo bucket so the deployed demo has attachments.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:33:50 +02:00
Julien HerrandClaude Opus 4.7 7226e718f7 feat(admin): paperclip indicator for emails with attachments
Show an inline paperclip icon before the subject in the admin email
list when an email has attachments, with the count in a tooltip. Uses
the attachmentIds already stored in metadata, so no extra fetch.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 15:10:24 +02:00
Julien HerrandClaude Opus 4.7 f4e751e40b fix(favicon): add landing-page favicon to fix /favicon.ico 404
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:49:57 +02:00
Julien HerrandClaude Opus 4.7 766f2717a7 feat(entries): list email attachments with download links
The email detail page loaded the full EmailData (including attachments)
but never rendered them, so attachments were invisible. Add a conditional
"Attachments" section linking each file to /files/:id/:filename with name
and human-readable size.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:46:25 +02:00
Julien HerrandClaude Opus 4.7 d322bc1e92 docs(todo): add REST API with OpenAPI description item
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:36:16 +02:00
Julien HerrandClaude Opus 4.7 3ad0188bc0 feat(unsubscribe): RFC 8058 one-click unsubscribe on feed deletion
Capture each sender's List-Unsubscribe one-click URL during ingestion
(stored per sender in feed metadata, mirroring the iconDomain pattern) and
fire one-click POSTs via ctx.waitUntil when a feed is deleted, so newsletters
stop mailing the now-dead address. Tracked with a new unsubscribes_sent
counter surfaced on the status page and /api/stats.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:35:05 +02:00
Julien HerrandClaude Opus 4.7 eb12f21894 feat(favicon): per-feed icon from the last sender's domain
Resolve each feed's most recent sender domain and serve its favicon at
GET /favicon/:feedId, falling back to the project icon. Icons are fetched
in the background on ingestion (direct /favicon.ico then a DuckDuckGo
fallback), cached base64 in KV keyed by domain with a 1-week TTL so the
fetch only fires when absent. Exposed via RSS <image> / Atom <icon>/<logo>
and rendered in the admin feed list, plus a landing-page feature card.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:05:14 +02:00
Julien HerrandClaude Opus 4.7 d299c8891d feat(favicon): serve project favicon reusing the header envelope logo
Serve an inline SVG icon at /favicon.svg and /favicon.ico and link it
from the shared Layout and the standalone entry view, so the admin UI,
status page, and entry pages stop emitting /favicon.ico 404s. Doubles
as the fallback for the upcoming per-feed favicon feature.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 13:13:44 +02:00
Julien HerrandClaude Opus 4.7 a67baa71f4 feat(landing): add FAQ section and trim demo URL
Add a native details/summary accordion FAQ inspired by kill-the-newsletter,
rewritten for self-hosted differentiators; drop /admin from the demo URL.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 12:59:27 +02:00
Julien HerrandClaude Opus 4.7 b985e2c643 feat(status): redesign status page with hero, themed sections, responsive grid
Rework the public / status page from a flat uniform grid into a hero
featured metric plus four themed sections (Feeds, Emails, Distribution,
Instance). Add semantic colors (green success, red rejects/deletes),
relative timestamps with UTC tooltips, and derived metrics (net feeds,
acceptance rate, avg emails/feed, humanized uptime). Grid is fluid above
640px (auto-fit) and locks to two columns on mobile.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 12:39:57 +02:00
Julien HerrandClaude Opus 4.7 81d05e5774 feat(landing): move live counter above demo banner, point CTAs to demo root
Surface the live stats counter directly under the hero, ahead of the
"Try it live" banner. Demo CTAs (hero + banner) now open the demo root
instead of /admin so visitors land on the public status page first.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 10:43:30 +02:00
Julien HerrandClaude Opus 4.7 4db9fc1b8a fix(lint): close type-check gaps in client scripts and tooling
Remove unused import flagged by CI lint, then harden the toolchain so
such issues are caught before push:

- lint-staged now also matches .tsx/.jsx (previously .tsx files skipped
  the pre-commit eslint pass, which is how the error reached CI)
- eslint ignores generated client bundles (gitignored, not worth linting)
- typecheck now also runs the client tsconfig; the hand-written browser
  source was excluded from the root config and never type-checked
- consolidate the window global augmentations (showToast,
  parseJsonResponseOrThrow) into a single client globals.d.ts; the inline
  declare-global blocks failed (non-module files) and masked real errors

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 10:38:01 +02:00
Julien HerrandClaude Opus 4.7 6bfaa4dec7 fix(landing): eliminate mobile horizontal scroll
Fix the install step grid track (48px minmax(0,1fr)) so wide code blocks
and the WAF table no longer blow out the page width on mobile. Transpose
the WAF rate-limit table to a vertical layout (endpoints as columns,
settings as rows) and reclaim horizontal space with tighter mobile
padding.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 10:23:11 +02:00
Julien HerrandClaude Opus 4.7 fd6a1a945f feat(landing): show animated live demo stats counters
Add a "Live from the demo instance" section to the landing page that
fetches feeds_created and emails_received from the demo /api/stats and
counts them up on scroll into view. Make /api/stats publicly readable
(CORS *) and refresh the stale allowlist origins to kill-the.news.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 10:09:13 +02:00
Julien HerrandClaude Opus 4.7 d1959acad1 docs(todo): add favicon and RFC 8058 unsubscribe ideas
Add backlog items for a project favicon (also used as the per-feed
fallback), per-feed favicons resolved from the last sender's domain with
aggressive caching, and RFC 8058 one-click unsubscribe on feed deletion.
Include a detailed design breakdown for the per-feed favicon feature.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 09:57:59 +02:00
Julien HerrandClaude Opus 4.7 b534ce5bf8 feat(monitoring): add stats counters API and public status page
Add GET /api/stats exposing cumulative counters (feeds created/deleted,
emails received/rejected, recent date-times) plus live values (active
feeds, active WebSub subscriptions). Counters persist in a stats:counters
KV singleton and are incremented at the email-processing chokepoint and
feed create/delete paths. Replace the / → /admin redirect with a public
status page rendering these figures with a link to the admin.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 09:50:51 +02:00
Julien HerrandClaude Sonnet 4.6 f4d5edda0e feat(feeds): add configurable per-feed lifetime (TTL)
Replace the demo nightly KV wipe with a per-feed expiry. Feeds can be
given a lifetime at creation (and edited later); FEED_TTL_HOURS locks the
value server-side and greys out the UI field. Expired feeds stay visible
in admin (greyed, actions disabled), return 410 on rss/atom/entries, and
reject inbound emails. The scheduled handler now purges only expired
feeds (KV + R2 attachments) on an hourly global cron.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-23 09:05:48 +02:00
Julien HerrandClaude Sonnet 4.6 24c7d2a53e fix(release): create release before uploading bundle
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:50:39 +02:00
Julien HerrandClaude Sonnet 4.6 7e72022ffc fix(release): remove redundant cp and use env var for bundle path
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:49:25 +02:00
Julien HerrandClaude Sonnet 4.6 78117421fd fix(build): add wrangler.build.toml for CI and fix autoconfig
Wrangler 4.94+ introduced --experimental-autoconfig (default: true) which
fails in non-interactive CI environments. Without a committed wrangler.toml,
the release action build was broken.

- Add wrangler.build.toml with minimal config (placeholder KV ID, no secrets)
- Update build script to use wrangler.build.toml + --no-experimental-autoconfig

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:47:48 +02:00
Julien HerrandClaude Sonnet 4.6 1b31665b4f docs: update WAF rate limiting instructions for free tier accuracy
- Fix menu path: Security → Security rules (not Security → WAF)
- Add free tier limitations note: 1 rule max, 10s period/block cap
- Show recommended vs free tier limits side by side in table
- Remove HTTP method filter from conditions (not available in rate limiting rules)
- Note Terraform supports method filtering and longer periods (paid plan)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:38:41 +02:00
Julien HerrandClaude Sonnet 4.6 75a557d542 feat(admin): add site header and footer to all admin pages
Adds a minimal header with a branded link to kill-the.news and an
"admin" badge, plus a discreet footer with site link and GitHub
Sponsors link.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:23:48 +02:00
Julien HerrandClaude Sonnet 4.5 4a4c276859 feat: add sender blocklist with priority matching and quick-add dropdown
- Add `blocked_senders` field to FeedConfig (alongside existing `allowed_senders`)
- Refactor sender matching to priority-based logic: exact block > exact allow > domain block > domain allow, enabling exceptions (e.g. allow [email protected] despite blocking gmail.com)
- Add `POST /admin/feeds/:feedId/sender-filter` endpoint for quick allow/block from email detail view; returns 409 on conflict with opposite list
- Add ⋮ dropdown on From field in email detail with 4 options (allow/block sender/domain), inline success/error feedback
- Add blocked_senders textarea to create/edit feed forms
- 209 tests passing

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 23:09:53 +02:00
Julien HerrandClaude Sonnet 4.6 7b2b98d693 refactor: extract url helpers, add EMAIL_DOMAIN support
- Add src/utils/urls.ts with baseUrl, feedRssUrl, feedAtomUrl, feedUrl,
  feedEmailAddress, feedTopicPattern
- Add optional EMAIL_DOMAIN env var so web domain and email domain can
  differ (e.g. demo.kill-the.news serves feeds, @kill-the.news receives mail)
- Replace all inline domain template literals with the new helpers
- Remove unused site_url/feed_url fields from FeedConfig
- Remove unused feedPath param from fetchFeedData
- Extract verifyCallback() to deduplicate verifyAndStoreSubscription /
  verifyAndDeleteSubscription

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 22:38:29 +02:00
Julien HerrandClaude Sonnet 4.6 c64ceb4ee7 feat(site): add GitHub Sponsors links (nav, sponsor section, footer)
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 21:56:56 +02:00
Julien HerrandClaude Sonnet 4.6 6bf5ae0356 feat: landing page install guide, demo banner, WAF docs, nightly demo reset
- docs/index.html: nav links (Features/How it works/Install), hero CTAs
  (Try demo primary, Self-host, GitHub), demo banner with credentials,
  full 7-step installation section with WAF rate limiting guide (dashboard
  + Terraform) integrated as step 7
- wrangler-example.toml: cron trigger on demo env for nightly KV reset at 03:00 UTC
- src/index.ts: scheduled handler that wipes all EMAIL_STORAGE KV keys
- TODO.md: mark WAF rate limiting as done

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 21:50:42 +02:00
Julien Herr af721c081a chore: mark WebSub as done in TODO.md 2026-05-22 21:14:00 +02:00
Julien HerrandClaude Sonnet 4.5 a29e9ab372 feat: WebSub Atom support, HTML processing via linkedom, W3C badges
WebSub / PubSubHubbub:
- Hub now accepts both /rss/:id and /atom/:id topic URLs
- WebSubSubscription stores format ("rss" | "atom")
- notifySubscribers sends RSS or Atom XML with correct Content-Type
- verifyAndStoreSubscription sends correct topic URL per format
- CI paths-ignore docs/** to skip deploy on docs-only changes

HTML processing (linkedom + escape-html):
- New html-processor.ts: body extraction, script/iframe/object removal,
  event handler + javascript: URL stripping, mso-* style cleanup,
  plain text → <pre> with HTML escaping via escape-html
- feed-generator.ts and entries.ts use processEmailContent

Admin UI:
- W3C validation badges (Atom + RSS) on feed detail page

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 21:12:22 +02:00
Julien HerrandClaude Sonnet 4.6 1789870f27 fix(feed): use permalink URL as Atom entry id, strip mso-* inline styles
- Entry <id> was a non-URL string (timestamp + base64 snippet), which
  is invalid per the Atom spec; now uses the entry permalink URL which
  is both valid and stable across feed regeneration
- Strip mso-* properties from inline style attributes in extracted body
  content to eliminate the feed validator DangerousStyleAttr warning
  caused by Microsoft Office HTML in newsletter emails

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 21:12:22 +02:00
Julien HerrandClaude Sonnet 4.6 afed4464cf fix(feed): use permalink URL as Atom entry id, strip mso-* inline styles
- Entry <id> was a non-URL string (timestamp + base64 snippet), which
  is invalid per the Atom spec; now uses the entry permalink URL which
  is both valid and stable across feed regeneration
- Strip mso-* properties from inline style attributes in extracted body
  content to eliminate the feed validator DangerousStyleAttr warning
  caused by Microsoft Office HTML in newsletter emails

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:43:06 +02:00
Julien HerrandClaude Sonnet 4.6 4428f35dd4 fix(feed): add Atom link in emails page, fix HTML stripping, use request URL for self-link
- Add Atom Feed URL to the Feed Details card in the emails page
- Fix extractBodyContent to handle emails without a closing </body> tag
  (regex now falls back to capturing everything after the opening <body>)
- Use the actual request URL origin for atom:link rel="self" in RSS/Atom
  feeds, guaranteeing it always matches the document location regardless
  of how DOMAIN is configured

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:41:21 +02:00
Julien HerrandClaude Sonnet 4.6 bcc9640591 fix(feed): correct feed link, canonical id, and strip html wrapper from content
- link: computed as /admin/feeds/:id/emails instead of stale site_url from KV
- id: computed dynamically from baseUrl instead of stale feed_url from KV
- item description/content: strip <html><head><body> wrapper via extractBodyContent()
  so feed readers receive a body fragment, not a full HTML document

Fixes RSS validator warnings: SelfDoesntMatchLocation (stale KV domain) and
InvalidHTML (full HTML document inside <description>/<content:encoded>).
Adds 8 tests covering extractBodyContent and the new feed/atom link assertions.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:28:13 +02:00
Julien HerrandClaude Sonnet 4.6 780bf6c190 fix(admin): add Atom feed URL, unify container width, update branding
- Atom feed URL shown in both list and table views (new Atom column)
- Remove container-wide toggle — both views now use max-width 1200px
- Update dashboard title and login title to kill-the-news

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:21:49 +02:00
Julien HerrandClaude Sonnet 4.6 41f70143d1 design(admin): add Inter font, update page title to kill-the-news
Imports Inter 400/500/600/700 from Google Fonts to match the landing
page typography. Updates browser tab title format.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:21:42 +02:00
Julien HerrandClaude Sonnet 4.6 b95fbb468f design(admin): align with kill-the-news landing page visual identity
- variables.css: orange primary (#f6821f), dark bg (#0a0a0a), Inter font
- layout.css: orange radial glow, unified container 1200px (no width jump)
- components.css: orange buttons, remove backdrop-filter on inputs/cards

Fixes blurred form fields (double backdrop-filter), jarring width shift
between list/table views, and mismatched blue iOS aesthetic vs orange
Cloudflare identity of the site.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:21:34 +02:00
Julien HerrandClaude Sonnet 4.6 ba7add5f53 fix(demo): correct DOMAIN env var to demo.kill-the.news
The [env.demo] section pointed DOMAIN to kill-the.news while the
custom_domain route was demo.kill-the.news, causing feed/email URLs
to show the wrong domain in the admin UI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:21:26 +02:00
Julien Herr 3ccbd876b6 build(deps-dev): bump dev-dependencies group with 8 updates
Includes TypeScript 5→6 migration: moduleResolution node→bundler.
2026-05-22 17:35:01 +02:00
Julien Herr 03d069bfa3 build(deps): bump hono from 4.11.7 to 4.12.22
Fix TypeScript errors from stricter c.req.param() types (string | undefined).
2026-05-22 17:33:47 +02:00
Julien Herr ea400883dd build(deps): bump zod from 4.3.6 to 4.4.3 2026-05-22 17:32:50 +02:00
Julien Herr 7dd1225cde build(deps): bump feed from 5.2.0 to 5.2.1 2026-05-22 17:32:37 +02:00
Julien Herr 3b4a1ce98c build(deps): bump actions/setup-node from v4 to v6.4.0 2026-05-22 17:32:19 +02:00
Julien Herr 3fa17228a3 build(deps): bump actions/checkout from v4 to v6.0.2 2026-05-22 17:32:04 +02:00
Julien Herr ebfb8d4a60 fix(config): silence CSS module rule fallthrough warning 2026-05-22 17:28:02 +02:00
Julien Herr d657875322 feat(ci): auto-deploy demo instance on main push 2026-05-22 17:20:46 +02:00
Julien Herr 4d9418057e feat(deploy): add demo environment to wrangler config 2026-05-22 17:20:08 +02:00
Julien HerrandClaude Sonnet 4.6 edc1183e61 docs: rewrite CLAUDE.md to match actual codebase, remove .cursor
CLAUDE.md now reflects the real route set (atom, entries, files, hub,
email handler), src/lib/ layout, admin sub-modules, client script
pipeline, full Env bindings, and WebSub KV schema.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 15:53:22 +02:00
Julien HerrandClaude Sonnet 4.6 4316354ee5 refactor(styles): migrate TS template literal styles to real CSS files
Extract CSS from TypeScript template literals into standalone .css files
(variables.css, layout.css, components.css, utilities.css) and update
src/routes/admin/ui.tsx to import them directly via Wrangler text imports,
concatenating the strings at runtime for the inline <style> tag.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 15:44:08 +02:00
Julien HerrandClaude Sonnet 4.6 5d75682702 build: add Wrangler text rule for CSS imports
Add [[rules]] type = "Text" globs = ["**/*.css"] to wrangler-example.toml
so Wrangler bundles .css files as raw text strings importable in TypeScript.
Add src/types/css.d.ts to provide the module declaration for `import css from "*.css"`.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 15:43:49 +02:00
Julien HerrandClaude Sonnet 4.5 d64f703820 fix(ui): replace children cast to any with hono/jsx Child type
Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 15:35:10 +02:00
Julien HerrandClaude Sonnet 4.5 0e1592f8c6 build: gitignore generated client scripts, rebuild on npm install
Move src/scripts/generated/ to .gitignore — files are deterministic
build artefacts and don't belong in version control. Wire build:client
into prepare so they're regenerated automatically after npm install.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 13:53:33 +02:00
Julien HerrandClaude Sonnet 4.6 0f6670d0e9 refactor(admin): extract emailsScript to src/scripts/client/emails-page.ts
Moves the inline JS template literal from emails.tsx into a typed
TypeScript source file. The dynamic feedId value (previously interpolated
directly) is now passed via a window.__APP_CONFIG__ bootstrap script
injected immediately before the compiled static script in the HTML.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:50:54 +02:00
Julien HerrandClaude Sonnet 4.6 40f4b42cd5 refactor(admin): extract dashboardScript to src/scripts/client/dashboard.ts
Moves the 650-line inline JS template literal from admin.tsx into a
proper TypeScript source file with full type annotations. esbuild
compiles it to a minified IIFE committed in src/scripts/generated/,
which is imported and inlined into the HTML response as before.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:50:47 +02:00
Julien HerrandClaude Sonnet 4.6 0663861471 build: add client-side TypeScript compilation pipeline
Adds scripts/build-client.mjs which uses esbuild to compile TypeScript
files in src/scripts/client/ into minified IIFE bundles, then writes
them as TypeScript string-constant modules in src/scripts/generated/.

- Adds build:client npm script; wires it as prebuild and predev hooks
- Adds src/scripts/client/tsconfig.json with DOM lib for IDE support
- Excludes src/scripts/client/ from the root Worker tsconfig to avoid
  DOM type conflicts with the Cloudflare Workers runtime types

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:50:41 +02:00
Julien HerrandClaude Sonnet 4.6 671179acc7 build: add esbuild dev dependency
Installs esbuild to support compiling client-side TypeScript into
minified IIFE bundles that can be inlined into Worker HTML responses.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:50:31 +02:00
Julien HerrandClaude Sonnet 4.6 9bfebf4aa4 refactor(admin): migrate admin.ts to admin.tsx with JSX login and dashboard
Convert login page and dashboard GET routes from hono/html tagged template
literals to typed JSX using the <Layout> component. Extracts reusable
CopyIcon, CheckIcon, and CopyFieldInline components. Dashboard inline
script (~650 lines) preserved exactly via dangerouslySetInnerHTML constant.
All auth logic, CSRF middleware, and API routes are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:46 +02:00
Julien HerrandClaude Sonnet 4.6 c9ab3839e4 refactor(admin): migrate emails.ts to emails.tsx with JSX rendering
Convert feed emails list and single email view GET routes from hono/html
tagged template literals to typed JSX. Extracts reusable CopyField and
SVG icon components. Inline page scripts are preserved verbatim via
dangerouslySetInnerHTML. Raw HTML display in single email view uses
dangerouslySetInnerHTML to avoid double-escaping pre-escaped content.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:38 +02:00
Julien HerrandClaude Sonnet 4.6 ecb85730e0 refactor(admin): migrate feeds.ts to feeds.tsx with JSX rendering
Convert the edit feed GET route from hono/html tagged template literals
to typed JSX using the <Layout> component. All CRUD routes and business
logic are preserved unchanged. textarea placeholder special characters
are now handled via JSX attribute escaping rather than &#10; entities.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:31 +02:00
Julien HerrandClaude Sonnet 4.6 996aa5e211 refactor(admin): migrate ui.ts to ui.tsx with JSX Layout component
Replace hono/html tagged template layout() function with a typed JSX
<Layout> component. CSS and interactive scripts are injected via
dangerouslySetInnerHTML to preserve exact output. clampText() is
preserved and re-exported for consumers.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:24 +02:00
Julien HerrandClaude Sonnet 4.6 f5bac1bd7d build: add hono/jsx support to tsconfig
Add jsx and jsxImportSource compiler options to enable hono/jsx server-side
rendering in .tsx files without per-file JSX pragmas.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:16 +02:00
Julien HerrandClaude Sonnet 4.6 d82344ac67 chore: remove TECH_DEBT.md — all tasks completed
All three phases fully remediated. P1-5 (WAF rate limiting) is
infrastructure-only and tracked in TODO.md.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 12:01:42 +02:00