Seven-day trial
Paperboy Filtered Firehose
-$49 / month
+$5 / month
Card required for the seven-day trial. Billing starts after the trial unless you cancel.
- Your chosen public RSS and Atom feeds @@ -201,7 +201,7 @@
- Cancel monthly
diff --git a/MARKETING.md b/MARKETING.md index 0dc3ffb..a79c876 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -5,9 +5,9 @@ ## Product - **Name:** Paperboy -- **One-liner:** Paperboy turns a user-built firehose of public feeds into a short, source-linked brief ranked around what matters to them. +- **One-liner:** Paperboy turns a user-built firehose of public feeds into a short, source-linked daily or weekly rollup ranked around what matters to them. - **Category:** Personal relevance filter -- **URL:** https://paperboy.kaibuilds.com/ +- **URL:** https://newpaperboy.com/ ## ICP (Ideal Customer Profile) @@ -16,7 +16,7 @@ - **Title/Role:** Technical founder or hands-on product/engineering operator - **Company size:** 1–20 people - **Industry:** AI, developer tools, and technical SaaS -- **Budget authority:** Usually direct for a $49/month tool; hypothesis, not buyer-validated +- **Budget authority:** Usually direct for a $5/month tool; hypothesis, not buyer-validated - **Trigger event:** Missing an API, pricing, dependency, or market change that affects active work - **Current solution:** Newsletters, RSS, GitHub notifications, bookmarks, and manual LLM summaries - **Pain severity:** Unknown; validate with preview-to-trial conversion and interviews @@ -59,20 +59,20 @@ ### User Segments -- **Trial subscriber:** Chooses public RSS/Atom feeds and a relevance filter, confirms the email, and starts a card-required seven-day trial. -- **Paid subscriber:** Keeps reading after the trial at the $49/month founding price. +- **Trial subscriber:** Chooses public RSS/Atom feeds, a relevance filter, and a daily or weekly cadence, confirms the email, and starts a card-required seven-day trial. +- **Paid subscriber:** Keeps reading after the trial at the $5/month founding price. ## Value Prop - **Core transformation:** From scanning a self-built information firehose to reading only the few items connected to current work. - **Key actions:** Choose or paste public feeds; describe current interests; name what to ignore; preview matches; confirm the email; start hosted checkout; self-manage or unsubscribe. -- **Activation moment:** Stripe confirms trialing or active billing and the management screen confirms daily delivery is active. +- **Activation moment:** Stripe confirms trialing or active billing and the management screen confirms the selected daily or weekly delivery is active. - **Aha moment:** The reader opens an original source or changes a concrete next step because of the brief. ## Business Model - **Monetization:** Card-required seven-day trial followed by a recurring subscription -- **Pricing:** $49/month after the trial until canceled +- **Pricing:** $5/month after the trial until canceled - **Stage:** Pre-PMF validation; the product measures preview, verification, checkout, paid activation, and retention as separate stages ## Current Marketing diff --git a/deploy/Caddyfile.paperboy b/deploy/Caddyfile.paperboy index 49faa8d..5dc6a28 100644 --- a/deploy/Caddyfile.paperboy +++ b/deploy/Caddyfile.paperboy @@ -1,4 +1,4 @@ -paperboy.kaibuilds.com { +newpaperboy.com, paperboy.kaibuilds.com { tls { on_demand } @@ -19,3 +19,7 @@ paperboy.kaibuilds.com { file_server } } + +www.newpaperboy.com { + redir https://newpaperboy.com{uri} permanent +} diff --git a/deploy/paperboy.env.example b/deploy/paperboy.env.example index eef7d05..0a0b41f 100644 --- a/deploy/paperboy.env.example +++ b/deploy/paperboy.env.example @@ -2,3 +2,9 @@ PAPERBOY_HOST_PORT=9122 PAPERBOY_OLLAMA_URL=http://agent:11434 PAPERBOY_LOG_LEVEL=INFO PAPERBOY_LOG_FORMAT=json +PAPERBOY_PUBLIC_URL=https://newpaperboy.com +PAPERBOY_STRIPE_SECRET_KEY= +PAPERBOY_STRIPE_PRICE_ID= +PAPERBOY_STRIPE_WEBHOOK_SECRET= +PAPERBOY_STRIPE_TRIAL_DAYS=7 +PAPERBOY_STRIPE_MONTHLY_PRICE_CENTS=500 diff --git a/docker-compose.yml b/docker-compose.yml index f628a5f..82cefa7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: PAPERBOY_EMAIL_TO: ${PAPERBOY_EMAIL_TO:-} PAPERBOY_EMAIL_REPLY_TO: ${PAPERBOY_EMAIL_REPLY_TO:-} PAPERBOY_BOUNCE_DOMAIN: ${PAPERBOY_BOUNCE_DOMAIN:-kaibuilds.com} - PAPERBOY_PUBLIC_URL: ${PAPERBOY_PUBLIC_URL:-https://paperboy.kaibuilds.com} + PAPERBOY_PUBLIC_URL: ${PAPERBOY_PUBLIC_URL:-https://newpaperboy.com} PAPERBOY_MANAGE_SECRET: ${PAPERBOY_MANAGE_SECRET:-} PAPERBOY_TRACKING_SECRET: ${PAPERBOY_TRACKING_SECRET:-} PAPERBOY_KAIBUILDS_CAPTURE_URL: ${PAPERBOY_KAIBUILDS_CAPTURE_URL:-} @@ -31,6 +31,7 @@ services: PAPERBOY_STRIPE_WEBHOOK_SECRET: ${PAPERBOY_STRIPE_WEBHOOK_SECRET:-} PAPERBOY_STRIPE_PRICE_ID: ${PAPERBOY_STRIPE_PRICE_ID:-} PAPERBOY_STRIPE_TRIAL_DAYS: ${PAPERBOY_STRIPE_TRIAL_DAYS:-7} + PAPERBOY_STRIPE_MONTHLY_PRICE_CENTS: ${PAPERBOY_STRIPE_MONTHLY_PRICE_CENTS:-500} PAPERBOY_FAST_MODEL: ${PAPERBOY_FAST_MODEL:-llama3.2:3b} PAPERBOY_RESEARCH_MODEL: ${PAPERBOY_RESEARCH_MODEL:-qwen2.5:7b} PAPERBOY_LOG_LEVEL: ${PAPERBOY_LOG_LEVEL:-INFO} @@ -72,7 +73,7 @@ services: PAPERBOY_EMAIL_TO: ${PAPERBOY_EMAIL_TO:-} PAPERBOY_EMAIL_REPLY_TO: ${PAPERBOY_EMAIL_REPLY_TO:-} PAPERBOY_BOUNCE_DOMAIN: ${PAPERBOY_BOUNCE_DOMAIN:-kaibuilds.com} - PAPERBOY_PUBLIC_URL: ${PAPERBOY_PUBLIC_URL:-https://paperboy.kaibuilds.com} + PAPERBOY_PUBLIC_URL: ${PAPERBOY_PUBLIC_URL:-https://newpaperboy.com} PAPERBOY_MANAGE_SECRET: ${PAPERBOY_MANAGE_SECRET:-} PAPERBOY_TRACKING_SECRET: ${PAPERBOY_TRACKING_SECRET:-} PAPERBOY_SCHEDULER_ONLY: ${PAPERBOY_SCHEDULER_ONLY:-paperboy.firehose_delivery} diff --git a/docs/design/paperboy-firehose/style-snapshot.json b/docs/design/paperboy-firehose/style-snapshot.json index 4156611..4a511f7 100644 --- a/docs/design/paperboy-firehose/style-snapshot.json +++ b/docs/design/paperboy-firehose/style-snapshot.json @@ -1210,7 +1210,7 @@ "background_color": "rgb(17, 22, 28)", "chars_per_line": 113, "is_large_text": false, - "sample_text": "Planned founding price: $49/month. No card or payment is collected yet." + "sample_text": "Founding price: $5/month after a seven-day trial. Card details stay on Stripe-hosted checkout." }, { "selector": "article.price-card > div > ul > li", @@ -1758,4 +1758,4 @@ "width": 1440, "height": 900 } -} \ No newline at end of file +} diff --git a/docs/productization/CADENCE-FEATURE.md b/docs/productization/CADENCE-FEATURE.md new file mode 100644 index 0000000..628d620 --- /dev/null +++ b/docs/productization/CADENCE-FEATURE.md @@ -0,0 +1,27 @@ +# Delivery cadence feature map + +## Feature: Daily and weekly rollups + +Paperboy's production subscription form is plain HTML and JavaScript. The KOMPETE React atoms and molecules are not present in this repository, so this change reuses Paperboy's existing form and status patterns instead of introducing a component system. + +### UI elements → existing Paperboy patterns + +| Element | Existing pattern | Use | +|---|---|---| +| Cadence control | Native `select` inside `.field` | Daily or weekly rollup | +| Weekly day control | Native `select` inside `.field` | Monday through Sunday; visible only for weekly cadence | +| Guidance | `.field-help` | Explain the 8:00 AM local schedule | +| Validation | `#intake-error.form-error` | Reuse the current accessible error region | +| Managed status | `#subscription-delivery` and `#management-summary` | Show the saved cadence and next delivery | +| Primary action | Existing `.button.button-wide` | Keep the current subscription action; no new button | + +### Styling scope + +- Reuse existing input/select, `.field`, `.field-help`, and responsive form rules. +- Add no custom button, card, loader, table, modal, or empty-state component. + +### Data contract + +- `cadence`: `daily` or `weekly`; defaults to `daily` for existing clients and rows. +- `weekly_day`: integer `0` through `6` using Python weekday numbering; defaults to Monday (`0`). +- Delivery remains 8:00 AM in the subscriber's IANA timezone. diff --git a/docs/productization/DESIGN_BRIEF.md b/docs/productization/DESIGN_BRIEF.md index 9429091..373b128 100644 --- a/docs/productization/DESIGN_BRIEF.md +++ b/docs/productization/DESIGN_BRIEF.md @@ -1,6 +1,8 @@ # Paperboy Daily Intelligence Brief — product design brief -Status: locked local implementation direction +> **Historical design record:** The implemented `product/` surface and current product strategy are authoritative. Price references below are superseded; the active hosted offer is `$5/month` after a seven-day trial. + +Status: historical reference; not current launch authority Updated: 2026-07-16 after product and commercial pivots Authority: cgallic/paperboy plus the operator's current product decision diff --git a/docs/productization/PRODUCT_STRATEGY.md b/docs/productization/PRODUCT_STRATEGY.md index 1bffb30..03962d7 100644 --- a/docs/productization/PRODUCT_STRATEGY.md +++ b/docs/productization/PRODUCT_STRATEGY.md @@ -1,287 +1,76 @@ -# Paper Boy product strategy +# Paperboy product strategy **Decision date:** 2026-07-16 -**Recommendation:** Sell a hosted **Paperboy Daily Intelligence Brief** to revenue-bearing technical founders and operators at a primary price of **$49/month** while keeping the current self-hosted GitHub project free as distribution and proof. The brief should rank and synthesize explicitly forwarded newsletters, public news/research/data, and selected GitHub repositories into one evidence-backed morning edition. Repo Radar is one section, not the whole product. -## Source-of-truth note +**Active offer:** Card-required seven-day trial, then **$5/month** until canceled. -Current-product claims below are grounded in commit [`526ae15`](https://github.com/cgallic/paperboy/tree/526ae15ed76b501a53e3c0439b7215d234d8ec7c) of the exact public repo, `cgallic/paperboy`. +**Product:** Working hosted self-serve daily or weekly rollup at `https://newpaperboy.com/`. -Live read-only checks were also run on 2026-07-16: +**Acquisition option:** Keep the self-hosted/open-source project free. -- **Brain host/source:** `agent:/home/connor/brain/events.db`. Relevant exact events include `1286611` (`claude-laptop/message`, the 10,000-to-12 positioning and open-brane relationship), `1187894` (`claude-laptop/message`, Paper Boy as a Discord daily digest and desired action-queue demo), and `1188408` (`claude-laptop/message`, the explicit file-first, no-external-send action-queue contract). -- **Gmail source:** live Gmail API via DWD for `connor@kaicalls.com` and `me@connorgallic.com`. Exact searches for `"Paper Boy"`, `paperboy`, `"cgallic/paperboy"`, `from:github paperboy`, and `subject:paperboy` found no current Paper Boy product thread. The three `paperboy` hits in `me@connorgallic.com` were unrelated 2018-2019 newsletter mentions (thread IDs `16aa268aed0e00e2`, `16a9701a0f54d7e0`, `1633b15f8a98ea8d`). No product requirement is inferred from those emails. +## Product truth -The 2026-07-16 operator direction is therefore treated as the forward product brief: **a hosted morning email combining explicitly forwarded email/newsletters, public news/research/data, and selected GitHub repositories.** These are proposed hosted inputs/delivery, not capabilities represented as already existing. “Forwarded email” means messages intentionally sent to a Paperboy address, not broad mailbox access. +Paperboy lets a user choose up to six public RSS or Atom feeds, describe what matters and what to ignore, select daily or weekly delivery, confirm their email, and receive an automatically ranked and deduplicated rollup. The live product includes feed validation, bounded scans, secure management links, scheduled email delivery, unsubscribe handling, Stripe Checkout/portal/webhook code, privacy, terms, and consent-based analytics. -## Correction guardrail +Paperboy does not connect to Gmail or read an inbox. It is self-serve and does not include installation, research, or consulting labor. -Discovery correctly identified Paperboy's existing core job as a daily news-and-research digest. The first product synthesis then overrode that evidence with a narrow paid-wedge heuristic and made Repo Impact the product. That was the wrong abstraction. +## Initial customer -Future product decisions must state three things in order: +Start with technical founders and hands-on product or engineering leads who already monitor several public sources and can describe a concrete relevance filter. The strongest trigger is not general curiosity; it is repeatedly missing an API, pricing, dependency, research, or product change that affects active work. -1. **Existing core job:** rank a large news/research firehose into a small daily digest. -2. **Requested expansion:** add explicitly forwarded newsletters, public data, selected GitHub sources/context, and hosted email delivery. -3. **Paid delta:** cross-source relevance ranking, synthesis, evidence, feedback learning, and quiet-day discipline. +Exclude general-news readers, teams requiring private-source access, and enterprise buyers needing SSO, procurement, team administration, or compliance guarantees. -Reject any wedge that changes the core job merely because one input or section appears easier to sell. **Repo Radar is a section of the Daily Brief, not Paperboy's product identity.** +## Positioning -Commercially, preserving the core job does not mean preserving the OSS audience, easiest beta, or lowest price. Optimize for the product that can create durable revenue: choose an ICP with an expensive information problem, charge against the value of better decisions, and keep delivery costs bounded. Reject both failure modes—turning Paperboy into a narrow repo utility and pricing a decision-intelligence product like a commodity reader. +**Build your firehose. Read only what matters.** -## Current product truth +The user controls the source list and relevance instructions. Paperboy produces a short, source-linked edition instead of another chronological inbox. Lead with this visible mechanism, not unsupported productivity or revenue claims. -| Area | What exists now | Evidence | -|---|---|---| -| Product shape | A self-hosted daily Discord digest scored by a local LLM. The repo explicitly says “No cloud, no SaaS, no account.” | [`README.md:6-7`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/README.md#L6-L7) | -| Inputs | RSS/Atom news plus research papers from arXiv, Hugging Face Daily Papers, and Semantic Scholar. There is no selected-repository or Gmail ingest in the current package. | [`README.md:65-75`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/README.md#L65-L75), [`research_papers.py:1-5`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/ingest/research_papers.py#L1-L5), [`research_papers.py:364-382`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/ingest/research_papers.py#L364-L382) | -| Personalization | `research-interests.md` is injected into the local Ollama scoring prompt. A 7-8 means a clear technique could upgrade a named system; 9-10 means strong fit and likely high impact. | [`research_papers.py:7-8`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/score/research_papers.py#L7-L8), [`research_papers.py:78-86`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/score/research_papers.py#L78-L86), [`research_papers.py:205-225`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/score/research_papers.py#L205-L225) | -| State | One local SQLite event log, with `PAPERBOY_DB` override and `events` plus `event_tags` tables. | [`db.py:14-15`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/db.py#L14-L15), [`db.py:50-69`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/db.py#L50-L69) | -| Delivery | The morning queue selects up to 12 unanswered/unrated prompts and sends them by Discord bot or webhook. Email delivery does not exist. | [`prompt_digest.py:1-18`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/digest/prompt_digest.py#L1-L18), [`prompt_digest.py:181-184`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/digest/prompt_digest.py#L181-L184), [`discord_post.py:6-12`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/discord_post.py#L6-L12) | -| Action queue | A deterministic module promotes strong items into pending JSONL and Markdown records. It sends nothing; a human must change status before a separate downstream agent acts. | [`action_queue.py:13-25`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/digest/action_queue.py#L13-L25), [`action_queue.py:301-312`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/digest/action_queue.py#L301-L312), [`action_queue.py:320-349`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/digest/action_queue.py#L320-L349) | -| Operations | The supported install requires root, Linux/systemd, Python 3.10+, Ollama, and Discord. The architecture explicitly has no auth and no tests. | [`README.md:83-89`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/README.md#L83-L89), [`bootstrap.sh:20-37`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/scripts/bootstrap.sh#L20-L37), [`ARCHITECTURE.md:152-159`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/docs/ARCHITECTURE.md#L152-L159) | +## Packaging -“Reads 10,000 things and delivers 12” is strong positioning, not proven throughput telemetry. The repo tagline and Brain event `1286611` establish the intended promise; neither establishes a measured daily source count. +| Plan | Price | Purpose | +|---|---:|---| +| Paperboy OSS | Free | Acquisition, trust, and a buyer-managed self-hosted path | +| Paperboy hosted | Seven-day card-required trial, then `$5/month` | Primary commercial offer with automatic daily or weekly delivery and self-service management | -## Recommended wedge +Do not create a one-time Starter Pack or a managed setup offer. Do not publish a separate Affiliate Factory page over the working application. -### Paperboy Daily Intelligence Brief for technical founders and operators +## Funnel -**Promise:** Forward the newsletters you trust, choose the public sources and GitHub repositories you care about, and receive one ranked morning edition showing what changed, what connects across sources, why it matters, and what deserves action. +1. Visitor sees the filtered-firehose promise and product boundaries. +2. Visitor enters an email, sources, focus, ignore terms, and timezone. +3. Paperboy validates the public feeds and shows a ranked first cut. +4. Visitor explicitly confirms through the emailed double-opt-in link. +5. Verified user starts Stripe-hosted checkout and the seven-day trial. +6. A successful webhook activates the selected scheduled delivery. +7. Management, billing portal, unsubscribe, and suppression flows retain user control. -The edition is a brief, not a dump. It should take less than five minutes to scan and use stable sections: +## Measurement -1. **The three that matter** — the highest-ranked items across every source. -2. **Newsletter signals** — only novel claims from explicitly forwarded messages. -3. **News + data moves** — public developments or material metric changes. -4. **Research** — papers that clear the user's relevance threshold. -5. **Repo Radar** — external changes or watched-repo activity tied to a selected repo. -6. **One next move** — one recommended action, or an explicit “watch; do nothing.” +Track these as separate events and cohorts: -On a quiet day, Paperboy sends a one-line “nothing material changed” edition instead of padding the brief. Users may later opt to skip empty editions, but the beta should preserve the morning ritual while proving that the ranking bar is real. +- qualified landing session; +- setup start and valid subscription request; +- email confirmation; +- checkout start and Stripe redirect; +- successful webhook and trial activation; +- first successful delivery; +- open/click only when consented; +- trial-to-paid conversion; +- renewal, payment failure, cancel, unsubscribe, bounce, and suppression. -This wedge preserves Paperboy's original news-and-research identity and uses selected repositories as one personalization signal. Its differentiation is not generic summarization. It is **cross-source relevance ranking, synthesis of corroborating or conflicting signals, evidence links, and quiet-day discipline**. +Do not call setup attempts, email rows, or shared Stripe revenue Paperboy customers or revenue without product-level mapping. -## ICP and job to be done +## Launch gate -### Primary ICP +Production billing remains disabled until a recurring `$5/month` Stripe price is selected and these values are configured: -Revenue-bearing technical founders and hands-on product/engineering operators who: +- `PAPERBOY_STRIPE_SECRET_KEY` +- `PAPERBOY_STRIPE_PRICE_ID` +- `PAPERBOY_STRIPE_WEBHOOK_SECRET` -- run an AI, SaaS, developer-tool, technical agency, or data product where a missed model, API, competitor, research, or repository change can waste days or change a revenue decision; -- already receive multiple high-signal newsletters and follow public feeds, papers, datasets, or repositories; -- maintain or depend on at least one public GitHub repository, without requiring repo ownership to define their whole information need; -- personally reconcile overlapping sources and can act on the morning brief; -- can purchase a sub-$100 monthly tool without procurement; and -- already pay for developer productivity, research, or market-intelligence subscriptions. +After configuration, run an authorized checkout, webhook, portal, cancel, and delivery smoke before traffic. Deploy price and canonical-domain changes from current Paperboy main through the existing application release path. -The first cohort should skew toward AI and developer-tool founders because the current research scorer and source set are strongest there and external change velocity is high. The commercial qualification is not job title alone: the buyer should be able to name a recent missed signal that cost at least several hours, delayed a product decision, or created avoidable spend. Start with public repositories. Private-repository access introduces a trust and security sale that the first small-software version does not need. +## First growth loop -### Excluded from the first wedge - -- General-news consumers seeking a broad newspaper replacement. -- Hobbyists whose information problem is interesting but not economically costly. -- Users who want a read-later archive rather than a ranked morning decision surface. -- Researchers or employees who need reimbursement/procurement for a $49 tool. -- Engineering managers seeking standup replacement or velocity analytics. -- Enterprise competitive-intelligence teams. -- Teams that require private-code indexing, SSO, audit logs, or procurement. - -### JTBD - -> When trusted newsletters, public news, research, data, and repository changes pile up in separate places, give me one ranked morning view of what is materially new, how the signals connect, and what deserves my attention or action. - -### Sharp pain - -The pain is not merely “too much content.” Cheap readers already summarize feeds and newsletters. The sharper pain is that the same underlying change arrives fragmented across an email, a public article, a paper, a dataset, and a repository release. The user must deduplicate it, judge source quality, resolve disagreement, and decide whether it matters. Paperboy must answer **what is materially new, what the sources collectively imply, and what deserves action**. Repo Radar supplies extra context when code is affected; it is not required for every useful item. - -## Recurring value loop - -```text -forward chosen newsletters + select feeds/data/repos - ↓ -build an explicit interest, source, and repo-context profile - ↓ -ingest new items with source identity and evidence - ↓ -deduplicate, rank relevance, and synthesize cross-source claims - ↓ -assemble the ranked morning edition + one next move - ↓ -useful / not useful / already knew / acted / wrong-context feedback - ↓ -tune sources, interests, ranking, sections, and thresholds -``` - -The existing `research-interests.md` contract is the seed for this loop, but user feedback is not yet implemented. Current digest code only excludes items after separate feedback/answer events exist; it does not provide the hosted feedback capture needed here ([`prompt_digest.py:55-86`](https://github.com/cgallic/paperboy/blob/526ae15ed76b501a53e3c0439b7215d234d8ec7c/paperboy/digest/prompt_digest.py#L55-L86)). - -## Activation moment - -Activation is not “forwarded an email,” “selected a repo,” or “opened the first brief.” It is: - -> Within 48 hours of adding at least three sources, the user marks one cross-source insight useful or acts on one item they would otherwise have missed. - -The onboarding preview should produce one sample edition before payment. A credible activation can be a newsletter claim corroborated by public data, a research result connected to a watched tool, or a Repo Radar item. If the preview merely restates individual summaries without ranking or synthesis, Paperboy has not activated the user. - -## Hosted MVP - -### In scope - -- Email address and a unique forwarding address for newsletters the user explicitly chooses. -- A bounded allowlist of user-selected public RSS/Atom feeds, research sources, public data endpoints, and up to 10 public GitHub repositories. -- A generated interest/context profile from onboarding answers, forwarded-source choices, and bounded public repo material such as README, dependency manifests, languages, releases, and topics. -- The existing research/news/paper intake where it is reliable, with source-level provenance preserved. -- One scheduled morning email surface with stable sections, evidence links, source attribution, and a hard item cap. -- Claim-level deduplication and lightweight cross-source synthesis that distinguishes corroboration, disagreement, and a single-source claim. -- Five feedback actions: useful, not useful, already knew, acted, wrong context. -- Per-user source, repo, threshold, delivery-time, and feedback configuration. -- Basic run, delivery, click, feedback, and cost telemetry. -- A clear deletion path for account/config data. - -### Architecture boundary - -Keep the current SQLite/systemd project self-hosted and single-user. Do not expose its database or mutate it into the hosted multi-tenant service. The hosted product should reuse scoring contracts and content shapes behind a separate scheduled service and tenant configuration layer. This preserves the free project and avoids turning a local, unauthenticated event log into a public SaaS backend. - -### Source boundaries - -- **Forwarded email:** accept only messages intentionally forwarded or subscribed to a per-user Paperboy address. Do not request Gmail OAuth, read an inbox, follow reply chains, or ingest attachments in MVP. -- **Public news/research/data:** use declared RSS/Atom feeds and documented public APIs/endpoints. Keep the source URL, publication time, retrieval time, and claim evidence. Do not add broad web crawling or paywall bypass. -- **GitHub:** accept selected public repository URLs and documented public metadata/content. Use repositories as both watched sources and relevance context. Do not request private-code scopes. -- **User control:** every source must be visible, removable, and attributable in the edition. Deleting a source stops future ingest; deleting the account removes tenant configuration and retained forwarded content. - -### Explicit non-goals - -- Private repository access or broad GitHub OAuth scopes. -- Gmail/Outlook mailbox access, broad inbox search, reply drafting, or sending as the user. Explicit forwarding is the only email intake. -- Discord, Slack, mobile app, or dashboard delivery for hosted v1. -- Autonomous PR creation, code changes, purchases, or external actions. -- Team accounts in the initial Operator build, plus standup reports, analytics dashboards, SSO, or RBAC. The later Small Team offer is shared delivery/feedback only. -- A read-later archive, full-text personal search engine, vector database, general personal memory product, or open-brane hosting. -- Affiliate-ranked recommendations. -- A marketplace or “agent factory.” - -## Pricing hypothesis - -### Offer - -- **Self-hosted:** free, MIT GitHub project; users bring Linux, Ollama, Discord, and their own operation. -- **Operator — primary offer:** **$49/month** for one recipient, explicit newsletter forwarding, up to 30 declared public/news/research/data sources, up to 10 public repos, one scheduled Daily Intelligence Brief, evidence, feedback learning, and no infrastructure setup. -- **Annual:** test **$490/year** only after month-two retention clears the threshold below. Do not use an annual discount to hide weak monthly retention. -- **Concierge setup — optional during validation:** **$199 one time** for source mapping, initial ranking calibration, and a live first-edition review. Keep the subscription usable without this service and remove the manual dependency as onboarding improves. -- **Small Team — later expansion:** **$149/month** for up to five recipients sharing one source set and edition. Add only after Operator retention; keep it to shared delivery and feedback, not SSO, dashboards, permissions, or enterprise procurement. - -Do not launch a permanently free hosted tier during PMF testing. The free self-hosted project is already the open-source acquisition path. Offer a no-card sample brief or short trial instead. - -### $49 pressure test - -As of 2026-07-16, current primary pricing pages show: - -| Alternative | Current offer and price | What it proves | Paper Boy gap/opportunity | -|---|---|---|---| -| GitHub notifications | GitHub natively sends activity for watched repos to its inbox, mobile, or email; users can filter issue, PR, release, security, and discussion activity. [Official docs](https://docs.github.com/en/subscriptions-and-notifications/concepts/about-notifications), [subscription controls](https://docs.github.com/en/subscriptions-and-notifications/how-tos/managing-subscriptions-for-activity-on-github/managing-your-subscriptions) | Repo email is already native and carries no separate product purchase. | Repo Radar must synthesize repo activity with outside news/research rather than forward notifications. | -| Digest | Starter is **$6/month** and includes AI newsletter summaries, RSS, news sources, and GitHub Trending sources. [Official pricing](https://usedigest.com/pricing/) | Multi-source daily email aggregation is inexpensive. | A $49 edition must demonstrably improve decisions through ranking, claim synthesis, and evidence—not just collect widgets. | -| Inoreader | Pro is **$7.50/month billed annually or $9.99 monthly**, with monitoring feeds, filters/rules, AI summaries, and scheduled email digests. [Official pricing](https://www.inoreader.com/pricing/feature/subscriptions) | Power users can build sophisticated filters far below $49. | Paperboy must remove rule-building work and supply cross-source judgment plus a strict relevance bar for a buyer with an expensive miss problem. | -| Digest AI | Pro is **$15/month** for unlimited newsletter sources, advanced summaries, categories, and custom delivery time. [Official pricing](https://www.newsletterdigest.tech/pricing) | Inbox-summary buyers already see a $15 reference price. | Paperboy earns a premium only when it joins forwarded claims to public evidence and makes the result decision-ready. | -| Git Digest | **$25/month billed annually** for unlimited teammates/repos, code-change summaries, and daily/weekly email or Slack. [Official product and pricing](https://gitdigest.ai/) | Customers pay for selected-repo summaries that replace coordination work. | Paper Boy should not compete on standups; it should cover external change → internal impact. | -| AlphaSignal | Free covers top AI stories/models/repos; Pro is **$350/year ($29.17/month)** for a work-personalized, ad-free newsletter covering models, repos, and papers. [Official pricing](https://alphasignal.ai/pricing) | The closest public-news/research promise establishes a roughly $29 individual reference price. | Paperboy's $49 price requires a stronger outcome: user-chosen private-to-them forwarded sources, visible evidence, cross-source synthesis, feedback learning, and one next move. | -| Feedly Market Intelligence | Standard is **$1,600/month billed annually**, including 100 AI feeds, newsletter templates, collaboration, and up to 10 seats. [Official pricing](https://feedly.com/market-intelligence/pricing) | Enterprise intelligence is a different budget and buyer. | Avoid enterprise features; win on individual setup speed and specificity. | - -**Conclusion:** Lead with **$49/month**, not $12. A $12 price positions Paperboy beside commodity readers and attracts buyers with low-cost curiosity rather than an expensive decision problem. $49 is still self-serve small-software pricing, sits above the $25-$29 specialist references, and remains far below enterprise intelligence. That premium is earned only if the product combines the buyer's chosen sources, produces cross-source conclusions with evidence, and reliably changes a decision. Feedly's enterprise price shows budget headroom, but it is not direct proof that individuals will pay $49. - -### Price test - -Test actual checkout behavior after a personalized sample edition, not survey answers. Randomly quote **$29, $49, or $79/month** to 60 commercially qualified, activated prospects, 20 per cell. - -- Keep $49 as primary if at least 25% purchase and its conversion rate is no more than 25% lower than the $29 cell. -- Move to $79 if it produces the highest gross profit per activated prospect and its first-month activation/retention is not worse than $49 by more than 10 percentage points. -- Fall to $29 only if $29 converts at least 30% while $49 converts below 15% after the same sample quality. -- If every cell converts below 15%, fix the outcome and ICP; do not conclude that the answer is a cheaper summary product. - -### Revenue model and margin gates - -Subscription is the base business. Setup revenue funds early learning; Team expansion raises account value after the single-user loop retains. - -| Illustrative mix | Operator MRR | Team MRR | Total MRR | -|---|---:|---:|---:| -| 50 Operator + 10 Team accounts | $2,450 | $1,490 | **$3,940** | -| 200 Operator + 30 Team accounts | $9,800 | $4,470 | **$14,270** | - -These are arithmetic scenarios, not forecasts. Commercial gates: - -- Target Operator variable cost at or below **$5/account/month**; hard cap **$8**, leaving at least 84% gross margin at $49. -- Target Team variable cost at or below **$20/account/month**; hard cap **$25**, leaving at least 83% gross margin at $149. -- After concierge onboarding is removed, median support must stay below **15 minutes per Operator account per month**. -- Use OSS/audience-led acquisition first. Target organic/blended CAC at or below one month of Operator gross profit (about **$41**) and any paid CAC below **$120**, a sub-three-month gross-profit payback. -- If source fetch, inference, or support breaches the hard cap for two months, reduce source frequency/context, batch synthesis, or raise price before adding customers. - -## Distribution - -The free repo is a credibility and acquisition asset, not the hosted product's pricing anchor. - -1. **OSS conversion:** add a hosted sample CTA to the GitHub README and install docs: choose public sources/repos, receive one sample edition, then pay to schedule it. Do not cripple the self-hosted edition. -2. **Founder-led proof:** publish recurring before/after examples through Connor's existing X, LinkedIn, and email audience: “43 inputs became four material signals and one decision,” with every claim and source visible. -3. **Repo-owner outreach:** invite maintainers and technical founders whose public repos expose a clear stack. Generate a bounded sample from public context, then ask them to forward two newsletters to complete it. -4. **Public demonstration:** publish one weekly all-public Paperboy edition. It should demonstrate synthesis and evidence without leaking customer source lists or forwarded content. -5. **Referral loop:** after a customer records a useful action, offer one free month for a referred paid Operator. Cap credits so referrals improve CAC without creating a free hosted tier. - -Do not buy broad consumer-news traffic. Distribution should reach people who control a technical product and can recognize the cost of a missed signal. - -## Retention engine - -Retention should come from better judgment over time, not a growing unread archive. - -- Learn explicit source trust, topic weights, “already knew,” wrong-context, useful, and acted feedback. -- Preserve a small decision ledger linking an item, its evidence, the recommended move, and whether the user acted. -- Show a monthly value receipt: useful items, actions taken, sources pruned, and high-signal topics—not vanity counts of articles processed. -- Detect dead or repetitive sources and recommend removal; a smaller improving source set is a feature. -- Keep quiet-day behavior trustworthy. Padding the edition increases send volume but destroys the habit. - -The retention floor is at least one useful item in three of four weeks and one recorded action per month. If users open but do not rate, click evidence, or act, the product is an email habit without proven economic value. - -### Affiliate revenue - -Subscription should remain the base business. Affiliate monetization before trust is dangerous because it makes rankings suspect. Keep affiliate links out of the beta. After paid month-two retention clears 70%, test clearly disclosed vendor referral links only on items that already ranked without commercial inputs; referral availability, rate, or payout must never affect inclusion or order. Treat affiliate income as a later **5-15% revenue supplement**, not the reason the brief exists. Continue only if affiliate items have no worse usefulness/dismissal rate than non-affiliate items. - -## PMF risks - -1. **Commodity risk:** summarization and email delivery are already cheap or free; Paperboy must prove better decisions, not prettier summaries. -2. **Precision risk:** one irrelevant daily email can train the user to ignore all later emails. -3. **Source-quality risk:** forwarded newsletters and public sources can repeat the same unsupported claim, creating false confidence unless provenance and corroboration remain visible. -4. **Actionability risk:** the current deterministic action queue can produce plausible but generic tasks; “one action” may not be worth doing. -5. **Trust risk:** forwarded email retention and repository access create security questions. Broad mailbox or private-repo scope would amplify them. -6. **Cadence risk:** a required daily edition may tempt the system to pad quiet days instead of reporting that nothing material changed. -7. **Cost risk:** cross-source synthesis can expand inference and fetch costs faster than subscription revenue unless source frequency and context are bounded. -8. **OSS cannibalization risk:** capable users may self-host, leaving the hosted product with higher-support customers. -9. **Cold-start risk:** a user's chosen newsletters and feeds may have little overlap, leaving no convincing cross-source synthesis in the first edition. -10. **Affiliate conflict risk:** monetized recommendations can destroy the trust the subscription depends on. - -## Falsifiable PMF tests - -Run these in order. Do not use broad sign-up counts as PMF evidence. - -| Test | Method | Continue | Iterate | Kill or change wedge | -|---|---|---|---|---| -| Problem pull | Recruit 20 commercially qualified technical founders/operators. Ask each to forward two newsletters and choose at least one public feed/research/data source and one repo before showing a result. | At least 10 complete source setup; at least 6 ask to keep receiving the edition. | 6-9 complete setup. | Fewer than 6 complete setup. | -| Concierge activation | Manually configure the first 10 users and deliver a sample within 48 hours. | At least 7/10 mark one ranked or synthesized insight useful; at least 5/10 identify something they would otherwise have missed. | 5-6 useful. | Fewer than 5 useful. | -| Signal precision | Capture item-level useful/not-useful/already-knew/wrong-context feedback. | At least 70% useful across the first 100 rated items; fewer than 20% already-knew plus wrong-context. | 50-69% useful. | Below 50% useful after one tuning cycle. | -| Synthesis delta | Blind-test the Paperboy edition against source-by-source AI summaries for the same material. | At least 60% of users prefer Paperboy and can name a useful connection/evidence advantage. | 40-59% prefer it. | Below 40%; synthesis is not adding value. | -| Quiet-day trust | Seed days with no threshold-clearing material and days with only weak material. | At least 80% of weak items are excluded; quiet-day editions are not rated less trustworthy than normal editions. | 60-79% excluded. | Below 60%; ranking is manufacturing news. | -| Willingness to pay | Run the $29/$49/$79 checkout test after one personalized sample. | $49 converts at least 25% and stays within 25% of the $29 conversion rate, or $79 yields higher gross profit without materially worse activation. | $29 converts at least 30% while $49 lands at 15-24%. | Every cell below 15%; fix outcome/ICP before price. | -| Retention | Follow the first paid cohort for two billing cycles. | At least 70% retain into month two; at least 60% get a useful item in 3 of 4 weeks; at least 40% record one action/month. | 50-69% retain. | Below 50% month-two retention. | -| Unit economics | Measure actual fetch, inference, email, support, and refunds. | Operator variable cost at or below $5/account/month and support below 15 minutes/month. | Cost $5-$8. | Above $8 for two months after bounded-model optimization. | -| Affiliate trust, later only | After paid retention, show disclosed affiliate-eligible items to a small randomized cohort without changing rank. | Usefulness and dismissal remain within 5 percentage points of control. | 6-10 point degradation. | More than 10 point degradation; remove affiliate layer. | - -## Product decisions - -1. **Wedge:** Paperboy Daily Intelligence Brief for revenue-bearing technical founders and operators. -2. **Delivery:** one hosted morning email; explicit forwarding is also a bounded input. -3. **Sources:** forwarded newsletters, allowlisted public news/research/data, and up to 10 selected public GitHub repositories; no mailbox or private-repo access. -4. **Value unit:** a ranked, evidence-backed cross-source insight or decision—not a stack of summaries. Repo Radar is one section. -5. **Price:** lead at $49/month and validate against $29/$79 with real checkout behavior; do not default to $12 because it is easier to sell. -6. **Open-source boundary:** preserve the current self-hosted SQLite/systemd/Discord project; do not expose or multi-tenant it. -7. **Safety:** recommendations only. No autonomous code or external action. -8. **Monetization:** subscription first; no affiliate links during PMF beta. -9. **Build order:** sell paid concierge pilots and generate all-source editions manually → validate price/activation → build bounded forwarding intake and source registry → ranking/dedup/synthesis → edition renderer → scheduled email + feedback/value receipts → remove manual setup. Add the $149 small-Team delivery only after Operator retention. Do not build dashboards/private-repo/enterprise features before activation, synthesis-delta, payment, and margin thresholds clear. - -## One-sentence positioning - -**Paperboy turns the newsletters, news, research, data, and repositories you trust into one ranked morning edition—showing what is new, what connects, the evidence behind it, and what deserves your next move.** +Use the free repository, existing technical content, and already-live fleet distribution to send qualified users into the live setup flow. Publish examples that start from a real public-source bundle and a specific relevance filter. Measure which source/focus combinations verify, start trials, and receive a first successful edition. Expand only from retained cohorts; do not compensate for an unverified billing funnel with more page volume. diff --git a/paperboy/api/main.py b/paperboy/api/main.py index be0b4e1..f526d3f 100644 --- a/paperboy/api/main.py +++ b/paperboy/api/main.py @@ -10,6 +10,7 @@ Static files from product/ are served at / so the landing page works. """ + from __future__ import annotations import asyncio @@ -54,6 +55,7 @@ claim_billing_webhook, confirm_subscription, create_subscription, + delivery_schedule_label, finish_billing_webhook, get_subscription, get_subscription_by_email, @@ -70,6 +72,7 @@ logger = get_logger("api") + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: configure_logging() @@ -117,7 +120,9 @@ def _forward_verified_lead(subscription: dict) -> bool: "source": "paperboy_email_verified", "page": settings.public_url, "offer": "7-day trial", - "price": "$49/month", + "price": "$5/month", + "cadence": subscription["cadence"], + "weekly_day": subscription["weekly_day"], **{ key: value for key, value in attribution.items() @@ -174,6 +179,7 @@ async def log_requests( # API routes # --------------------------------------------------------------------------- + @app.get("/api/health") async def health() -> JSONResponse: report = run_all() @@ -248,7 +254,15 @@ async def capture_lead(request: Request) -> JSONResponse: "begin_checkout", "purchase", } -_ANALYTICS_PROPERTIES = {"source_count", "billing_status", "currency", "value", "transaction_id"} +_ANALYTICS_PROPERTIES = { + "source_count", + "cadence", + "weekly_day", + "billing_status", + "currency", + "value", + "transaction_id", +} _ANONYMOUS_ID = re.compile(r"^[A-Za-z0-9_-]{16,80}$") @@ -290,8 +304,7 @@ async def capture_hard_bounce(request: Request) -> Response: message = BytesParser(policy=policy.default).parsebytes(raw) report_text = raw.decode("utf-8", errors="replace") is_delivery_report = ( - message.get_content_type() == "multipart/report" - or "message/delivery-status" in report_text.casefold() + message.get_content_type() == "multipart/report" or "message/delivery-status" in report_text.casefold() ) is_hard_failure = bool( re.search(r"(?im)^Action:\s*failed\s*$", report_text) @@ -414,7 +427,7 @@ async def preview_firehose(request: Request) -> JSONResponse: @app.post("/api/firehose/subscribe") async def subscribe_firehose(request: Request) -> JSONResponse: - """Preview a firehose, then persist it for automatic daily delivery.""" + """Preview a firehose, then persist it for automatic daily or weekly delivery.""" content_length = request.headers.get("content-length") if content_length: try: @@ -430,7 +443,16 @@ async def subscribe_firehose(request: Request) -> JSONResponse: except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise HTTPException(status_code=400, detail="request body must be valid JSON") from exc try: - email, sources, focus, ignore, attribution, timezone_name = validate_subscription_payload(payload) + ( + email, + sources, + focus, + ignore, + attribution, + timezone_name, + cadence, + weekly_day, + ) = validate_subscription_payload(payload) except SubscriptionValidationError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @@ -459,6 +481,8 @@ async def subscribe_firehose(request: Request) -> JSONResponse: ignore, attribution, timezone_name, + cadence, + weekly_day, ) except SubscriptionSuppressedError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @@ -472,6 +496,8 @@ async def subscribe_firehose(request: Request) -> JSONResponse: "status": "pending_verification", "preview": preview, "confirmation_queued": True, + "cadence": cadence, + "weekly_day": weekly_day, } ) @@ -490,7 +516,9 @@ async def firehose_subscription_status(token: str) -> JSONResponse: status = ( "unsubscribed" if subscription["unsubscribed_at"] or subscription["suppressed"] - else "active" if subscription["verification_status"] == "verified" else "pending_verification" + else "active" + if subscription["verification_status"] == "verified" + else "pending_verification" ) return JSONResponse( { @@ -501,6 +529,9 @@ async def firehose_subscription_status(token: str) -> JSONResponse: "focus": subscription["focus"], "ignore": subscription["ignore"], "timezone": subscription["timezone"], + "cadence": subscription["cadence"], + "weekly_day": subscription["weekly_day"], + "delivery": delivery_schedule_label(subscription), "billing_status": subscription["billing_status"], "created_at": subscription["created_at"], "last_sent_at": subscription["last_sent_at"], @@ -548,6 +579,9 @@ async def confirm_firehose_subscription(token: str) -> JSONResponse: "checkout_available": settings.billing_enabled, "portal_available": False, "timezone": subscription["timezone"], + "cadence": subscription["cadence"], + "weekly_day": subscription["weekly_day"], + "delivery": delivery_schedule_label(subscription), "sources": subscription["sources"], "focus": subscription["focus"], "ignore": subscription["ignore"], @@ -652,9 +686,7 @@ async def stripe_webhook(request: Request) -> JSONResponse: return JSONResponse({"ok": True, "status": outcome}) -_PIXEL = bytes.fromhex( - "47494638396101000100800000ffffff00000021f90401000000002c00000000010001000002024401003b" -) +_PIXEL = bytes.fromhex("47494638396101000100800000ffffff00000021f90401000000002c00000000010001000002024401003b") @app.get("/api/t/o/{token}.gif") diff --git a/paperboy/config.py b/paperboy/config.py index e8aa03b..3345e48 100644 --- a/paperboy/config.py +++ b/paperboy/config.py @@ -68,7 +68,7 @@ class PaperboyConfig(BaseSettings): bounce_domain: str = "kaibuilds.com" # Hosted firehose - public_url: str = "https://paperboy.kaibuilds.com" + public_url: str = "https://newpaperboy.com" manage_secret: str | None = None tracking_secret: str | None = None kaibuilds_capture_url: str | None = None @@ -78,7 +78,7 @@ class PaperboyConfig(BaseSettings): stripe_webhook_secret: str | None = None stripe_price_id: str | None = None stripe_trial_days: int = Field(default=7, ge=0, le=90) - stripe_monthly_price_cents: int = Field(default=4900, ge=50) + stripe_monthly_price_cents: int = Field(default=500, ge=50) stripe_currency: str = "usd" # API diff --git a/paperboy/confirmation_delivery.py b/paperboy/confirmation_delivery.py index c8863ee..5527f66 100644 --- a/paperboy/confirmation_delivery.py +++ b/paperboy/confirmation_delivery.py @@ -1,4 +1,5 @@ """Send queued double-opt-in confirmation messages with bounded retries.""" + from __future__ import annotations import html @@ -14,6 +15,7 @@ claim_pending_confirmations, confirmation_token, confirmation_url, + delivery_schedule_label, finish_confirmation_attempt, ) @@ -22,12 +24,14 @@ def _render_confirmation(subscription: dict[str, Any]) -> tuple[str, str, str]: url = confirmation_url(confirmation_token(subscription)) + schedule = delivery_schedule_label(subscription) subject = "Confirm your Paperboy firehose" text = "\n".join( [ "Confirm your Paperboy firehose", "", f"Focus: {subscription['focus']}", + f"Schedule: {schedule}", "", f"Confirm: {url}", "", @@ -38,6 +42,7 @@ def _render_confirmation(subscription: dict[str, Any]) -> tuple[str, str, str]: '
' "Focus: {html.escape(str(subscription['focus']))}
" + f"Schedule: {html.escape(schedule)}
" f'' "This link expires in 48 hours. Ignore this email if you did not request it.
" diff --git a/paperboy/db.py b/paperboy/db.py index 60ac893..bfbdca5 100644 --- a/paperboy/db.py +++ b/paperboy/db.py @@ -17,6 +17,7 @@ For backwards compatibility with the source codebase, BRAIN_ROOT / BRAIN_DB are honored if PAPERBOY_* are unset. """ + from __future__ import annotations import os @@ -84,6 +85,8 @@ def connect() -> sqlite3.Connection: token_nonce TEXT NOT NULL UNIQUE, active INTEGER NOT NULL DEFAULT 0, timezone TEXT NOT NULL DEFAULT 'UTC', + cadence TEXT NOT NULL DEFAULT 'daily', + weekly_day INTEGER NOT NULL DEFAULT 0, verification_status TEXT NOT NULL DEFAULT 'pending', verification_token_hash TEXT, verification_token_nonce TEXT, @@ -179,6 +182,8 @@ def connect() -> sqlite3.Connection: _ADDITIVE_COLUMNS = { "firehose_subscriptions": { "timezone": "TEXT NOT NULL DEFAULT 'UTC'", + "cadence": "TEXT NOT NULL DEFAULT 'daily'", + "weekly_day": "INTEGER NOT NULL DEFAULT 0", "verification_status": "TEXT NOT NULL DEFAULT 'pending'", "verification_token_hash": "TEXT", "verification_token_nonce": "TEXT", diff --git a/paperboy/email/sender.py b/paperboy/email/sender.py index 093dd39..56c4c9a 100644 --- a/paperboy/email/sender.py +++ b/paperboy/email/sender.py @@ -107,7 +107,7 @@ def send_raw( msg["Subject"] = subject msg["From"] = from_address msg["To"] = to_addr - outbound_message_id = message_id or make_msgid(domain="paperboy.kaibuilds.com") + outbound_message_id = message_id or make_msgid(domain="newpaperboy.com") msg["Message-ID"] = outbound_message_id if unsubscribe_url: parsed = urlsplit(unsubscribe_url) diff --git a/paperboy/firehose_delivery.py b/paperboy/firehose_delivery.py index 19fca17..805fb90 100644 --- a/paperboy/firehose_delivery.py +++ b/paperboy/firehose_delivery.py @@ -1,4 +1,5 @@ -"""Timezone-aware daily firehose delivery with bounded retries.""" +"""Timezone-aware scheduled firehose delivery with bounded retries.""" + from __future__ import annotations import html @@ -44,9 +45,7 @@ class DeliveryClaim: message_id: str -def _claim_delivery( - subscription_id: int, delivery_date: str, *, now: datetime | None = None -) -> DeliveryClaim | None: +def _claim_delivery(subscription_id: int, delivery_date: str, *, now: datetime | None = None) -> DeliveryClaim | None: """Claim a new/retry attempt without ever reclaiming a successful send.""" init_schema() now_dt = _utc_now(now) @@ -62,7 +61,7 @@ def _claim_delivery( if row is None: message_id = make_msgid( idstring=f"firehose-{subscription_id}-{delivery_date}", - domain="paperboy.kaibuilds.com", + domain="newpaperboy.com", ) cursor = conn.execute( """ @@ -93,7 +92,7 @@ def _claim_delivery( attempts += 1 message_id = str(row["message_id"] or "") or make_msgid( idstring=f"firehose-{subscription_id}-{delivery_date}", - domain="paperboy.kaibuilds.com", + domain="newpaperboy.com", ) conn.execute( """ @@ -149,7 +148,8 @@ def _render_digest( base = settings.public_url.rstrip("/") manage_url = f"{base}{manage_path}" unsubscribe_url = f"{base}{unsubscribe_path}" - subject = f"Paperboy: {len(items)} signal{'s' if len(items) != 1 else ''} for {delivery_date}" + cadence = "weekly" if subscription.get("cadence") == "weekly" else "daily" + subject = f"Paperboy {cadence} rollup: {len(items)} signal{'s' if len(items) != 1 else ''} for {delivery_date}" analytics_consent = bool(subscription.get("attribution", {}).get("_analytics_consent")) tracked_items: list[tuple[dict[str, Any], str]] = [] @@ -166,13 +166,10 @@ def _render_digest( tracked_items.append((item, item_url)) open_markup = "" if analytics_consent: - open_token = create_tracking_token( - int(subscription["id"]), "open", delivery_id=delivery_id - ) + open_token = create_tracking_token(int(subscription["id"]), "open", delivery_id=delivery_id) open_url = f"{base}/api/t/o/{open_token}.gif" open_markup = ( - f'' + f'' ) lines = [subject, "", f"Your focus: {subscription['focus']}", ""] @@ -219,7 +216,7 @@ def run_daily_deliveries( preview_builder: Callable[[list[str], str, list[str]], dict[str, Any]] | None = None, sender: Callable[..., dict[str, Any]] | None = None, ) -> dict[str, int]: - """Deliver due 08:00-local editions with at most three failed attempts.""" + """Deliver due daily or weekly 08:00-local editions with bounded retries.""" now_dt = _utc_now(now) build_preview = preview_builder or build_firehose_preview send = sender or send_raw @@ -244,9 +241,7 @@ def run_daily_deliveries( list(subscription["ignore"]), ) item_count = len(preview["items"]) - subject, text, body_html, unsubscribe_url = _render_digest( - subscription, preview, day, claim.delivery_id - ) + subject, text, body_html, unsubscribe_url = _render_digest(subscription, preview, day, claim.delivery_id) result = send( subject, text, @@ -268,9 +263,7 @@ def run_daily_deliveries( _finish_delivery(claim, status, detail, item_count, now=now_dt) summary[status] += 1 - logger.info( - "firehose_delivery_complete", extra={"event": "firehose_delivery_complete", **summary} - ) + logger.info("firehose_delivery_complete", extra={"event": "firehose_delivery_complete", **summary}) return summary diff --git a/paperboy/subscriptions.py b/paperboy/subscriptions.py index 7062997..46db279 100644 --- a/paperboy/subscriptions.py +++ b/paperboy/subscriptions.py @@ -1,4 +1,5 @@ """Durable, verified subscriptions and delivery-support primitives.""" + from __future__ import annotations import hashlib @@ -22,6 +23,8 @@ _BILLING_STATUSES = {"unpaid", "trialing", "active", "past_due", "canceled"} _VERIFICATION_TTL = timedelta(hours=48) _DELIVERY_HOUR = 8 +_CADENCES = {"daily", "weekly"} +_WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") class SubscriptionValidationError(ValueError): @@ -78,9 +81,25 @@ def _valid_timezone(value: Any) -> str: return timezone_name +def _valid_cadence(value: Any) -> str: + if value is None or value == "": + return "daily" + if not isinstance(value, str) or value.casefold() not in _CADENCES: + raise SubscriptionValidationError("cadence must be daily or weekly") + return value.casefold() + + +def _valid_weekly_day(value: Any) -> int: + if value is None or value == "": + return 0 + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 6: + raise SubscriptionValidationError("weekly_day must be an integer from 0 to 6") + return value + + def validate_subscription_payload( payload: Any, -) -> tuple[str, list[str], str, list[str], dict[str, Any], str]: +) -> tuple[str, list[str], str, list[str], dict[str, Any], str, str, int]: if not isinstance(payload, dict): raise SubscriptionValidationError("request body must be a JSON object") allowed = { @@ -89,6 +108,8 @@ def validate_subscription_payload( "focus", "ignore", "timezone", + "cadence", + "weekly_day", "consent", "analytics_consent", } | _ATTRIBUTION_KEYS @@ -106,14 +127,19 @@ def validate_subscription_payload( ) except PreviewValidationError as exc: raise SubscriptionValidationError(str(exc)) from exc - attribution = { - key: value - for key, value in payload.items() - if key in _ATTRIBUTION_KEYS or key.startswith("utm_") - } + attribution = {key: value for key, value in payload.items() if key in _ATTRIBUTION_KEYS or key.startswith("utm_")} if "analytics_consent" in payload: attribution["_analytics_consent"] = bool(payload["analytics_consent"]) - return email, sources, focus, ignore, attribution, _valid_timezone(payload.get("timezone")) + return ( + email, + sources, + focus, + ignore, + attribution, + _valid_timezone(payload.get("timezone")), + _valid_cadence(payload.get("cadence")), + _valid_weekly_day(payload.get("weekly_day")), + ) def _secret_path() -> Path: @@ -150,9 +176,7 @@ def _token_for_nonce(nonce: str) -> str: def _verification_token_for_nonce(nonce: str) -> str: - signature = hmac.new( - _management_secret(), f"verify:{nonce}".encode("ascii"), hashlib.sha256 - ).hexdigest() + signature = hmac.new(_management_secret(), f"verify:{nonce}".encode("ascii"), hashlib.sha256).hexdigest() return f"{nonce}.{signature}" @@ -210,6 +234,8 @@ def _decode_subscription(row: sqlite3.Row) -> dict[str, Any]: "token_nonce": str(row["token_nonce"]), "active": bool(row["active"]), "timezone": str(row["timezone"]), + "cadence": str(row["cadence"]), + "weekly_day": int(row["weekly_day"]), "verification_status": str(row["verification_status"]), "verification_token_nonce": row["verification_token_nonce"], "verification_expires_at": row["verification_expires_at"], @@ -248,11 +274,15 @@ def create_subscription( ignore: list[str], attribution: dict[str, Any] | None = None, timezone_name: str = "UTC", + cadence: str = "daily", + weekly_day: int = 0, ) -> tuple[dict[str, Any], str]: """Create/replace a pending subscription and queue a confirmation email.""" init_schema() email = _valid_email(email) timezone_name = _valid_timezone(timezone_name) + cadence = _valid_cadence(cadence) + weekly_day = _valid_weekly_day(weekly_day) if is_suppressed(email): raise SubscriptionSuppressedError("email address is suppressed") management_token_value, management_nonce, management_hash = _new_token() @@ -262,9 +292,7 @@ def create_subscription( expires_at = _iso(now_dt + _VERIFICATION_TTL) conn = connect() try: - row = conn.execute( - "SELECT id, created_at FROM firehose_subscriptions WHERE email = ?", (email,) - ).fetchone() + row = conn.execute("SELECT id, created_at FROM firehose_subscriptions WHERE email = ?", (email,)).fetchone() values = ( json.dumps(sources), focus, @@ -273,6 +301,8 @@ def create_subscription( management_hash, management_nonce, timezone_name, + cadence, + weekly_day, verification_hash, verification_nonce, expires_at, @@ -282,11 +312,12 @@ def create_subscription( """ INSERT INTO firehose_subscriptions (email, sources_json, focus, ignore_json, attribution_json, - token_hash, token_nonce, active, timezone, verification_status, + token_hash, token_nonce, active, timezone, cadence, weekly_day, + verification_status, verification_token_hash, verification_token_nonce, verification_expires_at, verification_attempts, verification_next_attempt_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 'pending', ?, ?, ?, 0, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 'pending', ?, ?, ?, 0, ?, ?, ?) """, (email, *values, now, now, now), ) @@ -300,6 +331,7 @@ def create_subscription( UPDATE firehose_subscriptions SET sources_json = ?, focus = ?, ignore_json = ?, attribution_json = ?, token_hash = ?, token_nonce = ?, active = 0, timezone = ?, + cadence = ?, weekly_day = ?, verification_status = 'pending', verification_token_hash = ?, verification_token_nonce = ?, verification_expires_at = ?, verification_sent_at = NULL, verification_attempts = 0, @@ -377,8 +409,7 @@ def confirm_subscription(token: str, *, now: datetime | None = None) -> dict[str conn.commit() return None suppressed = conn.execute( - "SELECT 1 FROM firehose_suppressions WHERE email = " - "(SELECT email FROM firehose_subscriptions WHERE id = ?)", + "SELECT 1 FROM firehose_suppressions WHERE email = (SELECT email FROM firehose_subscriptions WHERE id = ?)", (row["id"],), ).fetchone() if suppressed is not None: @@ -423,9 +454,7 @@ def _seed_legacy_confirmation_tokens(conn: sqlite3.Connection, now_dt: datetime) ) -def claim_pending_confirmations( - *, now: datetime | None = None, limit: int = 100 -) -> list[dict[str, Any]]: +def claim_pending_confirmations(*, now: datetime | None = None, limit: int = 100) -> list[dict[str, Any]]: """Atomically claim pending confirmation sends for one worker lease.""" if limit < 1 or limit > 500: raise ValueError("limit must be between 1 and 500") @@ -468,9 +497,7 @@ def claim_pending_confirmations( return [subscription for item in ids if (subscription := get_subscription_by_id(item))] -def finish_confirmation_attempt( - subscription_id: int, ok: bool, detail: str, *, now: datetime | None = None -) -> None: +def finish_confirmation_attempt(subscription_id: int, ok: bool, detail: str, *, now: datetime | None = None) -> None: now_dt = _utc_now(now) now_iso = _iso(now_dt) init_schema() @@ -493,11 +520,7 @@ def finish_confirmation_attempt( ) else: attempts = int(row["verification_attempts"]) - next_attempt = ( - _iso(now_dt + timedelta(minutes=15 * (2 ** max(0, attempts - 1)))) - if attempts < 3 - else None - ) + next_attempt = _iso(now_dt + timedelta(minutes=15 * (2 ** max(0, attempts - 1)))) if attempts < 3 else None conn.execute( """ UPDATE firehose_subscriptions @@ -568,9 +591,7 @@ def suppress_email(email: str, reason: str, detail: str = "") -> None: def bounce_address(subscription: dict[str, Any]) -> str: """Return a signed, non-PII envelope sender for bounce correlation.""" subscription_id = int(subscription["id"]) - signature = hmac.new( - _management_secret(), f"bounce:{subscription_id}".encode(), hashlib.sha256 - ).hexdigest()[:32] + signature = hmac.new(_management_secret(), f"bounce:{subscription_id}".encode(), hashlib.sha256).hexdigest()[:32] return f"paperboy-bounce+{subscription_id}.{signature}@{settings.bounce_domain}" @@ -583,9 +604,7 @@ def suppress_from_bounce_address(address: str, detail: str = "hard bounce report if match is None: return False subscription_id = int(match.group(1)) - expected = hmac.new( - _management_secret(), f"bounce:{subscription_id}".encode(), hashlib.sha256 - ).hexdigest()[:32] + expected = hmac.new(_management_secret(), f"bounce:{subscription_id}".encode(), hashlib.sha256).hexdigest()[:32] if not hmac.compare_digest(match.group(2), expected): return False subscription = get_subscription_by_id(subscription_id) @@ -612,9 +631,7 @@ def is_suppressed(email: str) -> bool: init_schema() conn = connect() try: - return conn.execute( - "SELECT 1 FROM firehose_suppressions WHERE email = ?", (normalized,) - ).fetchone() is not None + return conn.execute("SELECT 1 FROM firehose_suppressions WHERE email = ?", (normalized,)).fetchone() is not None finally: conn.close() @@ -641,29 +658,46 @@ def active_subscriptions() -> list[dict[str, Any]]: return [_decode_subscription(row) for row in rows] -def next_delivery_at( - subscription: dict[str, Any], *, now: datetime | None = None -) -> datetime: +def next_delivery_at(subscription: dict[str, Any], *, now: datetime | None = None) -> datetime: now_utc = _utc_now(now) zone = ZoneInfo(str(subscription.get("timezone") or "UTC")) local_now = now_utc.astimezone(zone) - candidate = datetime.combine(local_now.date(), time(_DELIVERY_HOUR), zone) - if candidate <= local_now: - candidate += timedelta(days=1) + cadence = str(subscription.get("cadence") or "daily") + if cadence == "weekly": + weekly_day = int(subscription.get("weekly_day", 0)) + days_ahead = (weekly_day - local_now.weekday()) % 7 + candidate = datetime.combine(local_now.date() + timedelta(days=days_ahead), time(_DELIVERY_HOUR), zone) + if candidate <= local_now: + candidate += timedelta(days=7) + else: + candidate = datetime.combine(local_now.date(), time(_DELIVERY_HOUR), zone) + if candidate <= local_now: + candidate += timedelta(days=1) return candidate.astimezone(timezone.utc) -def delivery_date_if_due( - subscription: dict[str, Any], *, now: datetime | None = None -) -> date | None: +def delivery_date_if_due(subscription: dict[str, Any], *, now: datetime | None = None) -> date | None: now_utc = _utc_now(now) zone = ZoneInfo(str(subscription.get("timezone") or "UTC")) local_now = now_utc.astimezone(zone) if local_now.time().replace(tzinfo=None) < time(_DELIVERY_HOUR): return None + if str(subscription.get("cadence") or "daily") == "weekly": + weekly_day = int(subscription.get("weekly_day", 0)) + if local_now.weekday() != weekly_day: + return None return local_now.date() +def delivery_schedule_label(subscription: dict[str, Any]) -> str: + """Return a concise, user-facing schedule in the subscription timezone.""" + timezone_name = str(subscription.get("timezone") or "UTC") + if str(subscription.get("cadence") or "daily") == "weekly": + weekly_day = int(subscription.get("weekly_day", 0)) + return f"Weekly · {_WEEKDAYS[weekly_day]} at 8:00 AM · {timezone_name}" + return f"Daily · 8:00 AM · {timezone_name}" + + def set_billing_state( subscription_id: int, status: str, @@ -703,14 +737,10 @@ def set_billing_state( def _rate_hash(kind: str, value: str) -> str: - return hmac.new( - _management_secret(), f"rate:{kind}:{value}".encode(), hashlib.sha256 - ).hexdigest() + return hmac.new(_management_secret(), f"rate:{kind}:{value}".encode(), hashlib.sha256).hexdigest() -def allow_subscription_attempt( - ip: str, email: str, *, now: datetime | None = None -) -> bool: +def allow_subscription_attempt(ip: str, email: str, *, now: datetime | None = None) -> bool: """Persistently limit subscribe attempts to 5/IP/hour and 3/email/hour.""" normalized_email = _valid_email(email) normalized_ip = (ip or "unknown").strip()[:128] @@ -724,28 +754,23 @@ def allow_subscription_attempt( conn = connect() try: conn.execute("BEGIN IMMEDIATE") - conn.execute( - "DELETE FROM firehose_subscription_attempts WHERE attempted_at < ?", (cleanup_before,) - ) + conn.execute("DELETE FROM firehose_subscription_attempts WHERE attempted_at < ?", (cleanup_before,)) ip_count = int( conn.execute( - "SELECT COUNT(*) FROM firehose_subscription_attempts " - "WHERE ip_hash = ? AND attempted_at > ?", + "SELECT COUNT(*) FROM firehose_subscription_attempts WHERE ip_hash = ? AND attempted_at > ?", (ip_hash, hour_ago), ).fetchone()[0] ) email_count = int( conn.execute( - "SELECT COUNT(*) FROM firehose_subscription_attempts " - "WHERE email_hash = ? AND attempted_at > ?", + "SELECT COUNT(*) FROM firehose_subscription_attempts WHERE email_hash = ? AND attempted_at > ?", (email_hash, hour_ago), ).fetchone()[0] ) allowed = ip_count < 5 and email_count < 3 if allowed: conn.execute( - "INSERT INTO firehose_subscription_attempts(ip_hash, email_hash, attempted_at) " - "VALUES (?, ?, ?)", + "INSERT INTO firehose_subscription_attempts(ip_hash, email_hash, attempted_at) VALUES (?, ?, ?)", (ip_hash, email_hash, now_iso), ) conn.commit() @@ -896,9 +921,7 @@ def claim_billing_webhook(event_id: str, event_type: str, *, now: datetime | Non conn = connect() try: conn.execute("BEGIN IMMEDIATE") - row = conn.execute( - "SELECT status FROM billing_webhook_events WHERE event_id = ?", (event_id,) - ).fetchone() + row = conn.execute("SELECT status FROM billing_webhook_events WHERE event_id = ?", (event_id,)).fetchone() received_at = _iso(_utc_now(now)) if row is None: conn.execute( @@ -928,9 +951,7 @@ def claim_billing_webhook(event_id: str, event_type: str, *, now: datetime | Non conn.close() -def finish_billing_webhook( - event_id: str, status: str, detail: str = "", *, now: datetime | None = None -) -> None: +def finish_billing_webhook(event_id: str, status: str, detail: str = "", *, now: datetime | None = None) -> None: if status not in {"processed", "failed", "ignored"}: raise ValueError("invalid billing webhook status") init_schema() diff --git a/product/README.md b/product/README.md index 0d1acc1..8dc4898 100644 --- a/product/README.md +++ b/product/README.md @@ -1,11 +1,12 @@ # Paperboy Filtered Firehose — validation surface This self-contained product surface is deployed at -`https://paperboy.kaibuilds.com/`. +`https://newpaperboy.com/`. The primary flow is real, bounded product behavior: - choose a Hacker News, arXiv AI, or GitHub Blog preset, or paste public RSS/Atom feed URLs; +- choose a daily rollup or a weekly rollup with a delivery weekday; - describe what should make an item relevant and optionally name noise to ignore; - call the same-origin `/api/firehose/subscribe` endpoint once; - validate and safely fetch up to six public feeds; @@ -27,7 +28,7 @@ checkout and only claims delivery is active when server billing status is The automatic subscription supports public RSS and Atom feeds. It does not connect to an inbox or auto-subscribe a user to email-only newsletters. The -live commercial offer is a card-required seven-day trial, then $49/month until +live commercial offer is a card-required seven-day trial, then $5/month until canceled. Card details remain on Stripe-hosted checkout. The existing setup/account/checkout routes remain clearly labeled fixture previews and are not linked from the primary live flow. @@ -54,21 +55,22 @@ git diff --check ## Golden path -1. Open the landing page and select **Start my daily brief**. -2. Enter the email where the daily brief should be delivered. +1. Open the landing page and select **Start my rollup**. +2. Enter the email where the rollup should be delivered. 3. Choose one quick-start feed or paste up to six public RSS/Atom feed URLs. 4. Describe what should make an item relevant. 5. Optionally provide comma-separated ignore terms. -6. Request the subscription and inspect the first ranked, source-linked results. -7. Open the emailed link and explicitly confirm the address. -8. Start the card-required seven-day trial through hosted checkout. -9. Use the returned management controls to refresh status or unsubscribe. +6. Choose daily delivery or a weekly delivery day and confirm the time zone. +7. Request the subscription and inspect the first ranked, source-linked results. +8. Open the emailed link and explicitly confirm the address. +9. Start the card-required seven-day trial through hosted checkout. +10. Use the returned management controls to refresh status or unsubscribe. ## Acceptance checklist - [ ] Hero positions Paperboy as a personal relevance filter, not a newsletter summarizer. - [ ] The many-inputs-to-few-signals transformation is visible above the fold. -- [ ] Every primary CTA says **Start my daily brief**; the header CTA is visually secondary. +- [ ] Every primary CTA says **Start my rollup**; the header CTA is visually secondary. - [ ] Public-feed presets and a direct URL intake are available. - [ ] Input rejects missing email, feeds, or relevance focus before network calls. - [ ] `/api/firehose/subscribe` is same-origin, bounded, and rejects private/non-public destinations. @@ -77,7 +79,7 @@ git diff --check - [ ] Pending requests render **Check your email** and never claim active delivery. - [ ] Successful requests persist email, source URLs, focus, ignore terms, time zone, consent, and attribution. - [ ] Confirmation requires an explicit POST and never exposes its token to analytics or lead capture. -- [ ] Daily delivery is called active only when billing status is `trialing` or `active`. +- [ ] Daily or weekly delivery is called active only when billing status is `trialing` or `active`. - [ ] Checkout accepts only a Stripe-hosted HTTPS URL and has a truthful unavailable state. - [ ] Tokenized status and unsubscribe actions work in-page without exposing tokens in copy. - [ ] Copy never claims Gmail access or automatic email-only newsletter subscription. diff --git a/product/app.js b/product/app.js index ab636a5..27da137 100644 --- a/product/app.js +++ b/product/app.js @@ -107,6 +107,8 @@ email: "", time: "07:30", timezone: "America/New_York", + cadence: "daily", + weeklyDay: 0, days: ["Mon", "Tue", "Wed", "Thu", "Fri"] }, billingState: "active", @@ -121,6 +123,7 @@ var activeStatusUrl = ""; var activeUnsubscribeUrl = ""; var activeManagementToken = ""; + var activeCadence = "daily"; var confirmationToken = ""; var screens = Array.prototype.slice.call(document.querySelectorAll("[data-screen]")); @@ -178,7 +181,7 @@ window.dataLayer = window.dataLayer || []; var event = { event: name, product: "paperboy" }; var safe = properties || {}; - ["source_count", "billing_status", "currency", "value", "transaction_id"].forEach(function (key) { + ["source_count", "cadence", "weekly_day", "billing_status", "currency", "value", "transaction_id"].forEach(function (key) { if (safe[key] !== undefined && safe[key] !== null) event[key] = safe[key]; }); window.dataLayer.push(event); @@ -293,7 +296,7 @@ if (result && typeof result.detail === "string") return result.detail; if (result && result.detail && typeof result.detail.message === "string") return result.detail.message; if (result && typeof result.message === "string") return result.message; - return "Paperboy could not save the daily brief request. Check the feeds and try again."; + return "Paperboy could not save the rollup request. Check the feeds and try again."; } function renderLivePreview(result) { @@ -436,7 +439,9 @@ if (!details) return; var status = String(details.status || result.status || "pending_verification").toLowerCase(); var billingStatus = String(details.billing_status || result.billing_status || "unpaid").toLowerCase(); - var delivery = details.delivery || details.schedule || "Daily · automatic"; + if (details.cadence === "weekly" || details.cadence === "daily") activeCadence = details.cadence; + var cadenceLabel = activeCadence === "weekly" ? "weekly" : "daily"; + var delivery = details.delivery || details.schedule || (cadenceLabel === "weekly" ? "Weekly · automatic" : "Daily · automatic"); var isVerified = status === "active" || status === "confirmed"; var isUnsubscribed = status === "unsubscribed"; var isDelivering = isVerified && (billingStatus === "trialing" || billingStatus === "active"); @@ -445,7 +450,7 @@ var paymentLabels = { unpaid: "Checkout required", trialing: "7-day trial", - active: "$49/month", + active: "$5/month", past_due: "Payment past due", canceled: "Canceled" }; @@ -484,6 +489,11 @@ timezone.textContent = "Time zone: " + details.timezone; summary.appendChild(timezone); } + if (details.cadence) { + var cadence = document.createElement("p"); + cadence.textContent = "Frequency: " + (details.cadence === "weekly" ? "Weekly" : "Daily"); + summary.appendChild(cadence); + } if (details.next_delivery_at && isDelivering) { var next = document.createElement("p"); next.textContent = "Next delivery: " + details.next_delivery_at; @@ -492,10 +502,10 @@ summary.hidden = !summary.childElementCount; document.getElementById("subscription-success-title").textContent = isUnsubscribed - ? "This daily brief is unsubscribed." + ? "This " + cadenceLabel + " rollup is unsubscribed." : billingStatus === "past_due" ? "Payment needs attention." : billingStatus === "canceled" ? "Your paid delivery is canceled." - : isDelivering ? "Your daily brief is active." + : isDelivering ? "Your " + cadenceLabel + " rollup is active." : isVerified ? "Email verified. Finish checkout to start delivery." : "Check your email."; document.getElementById("subscription-success-copy").textContent = isUnsubscribed @@ -503,7 +513,7 @@ : billingStatus === "past_due" ? "Delivery is paused until payment is updated. No new charge was attempted from this page." : billingStatus === "canceled" ? "Delivery is off. Your filter remains saved, and you can start a new hosted checkout when ready." : isDelivering ? "Paperboy will refresh the sources and deliver the strongest matches automatically." - : isVerified ? "Your filter is saved. Start the hosted seven-day trial checkout; then it is $49 per month until canceled." + : isVerified ? "Your filter is saved. Start the hosted seven-day trial checkout; then it is $5 per month until canceled." : "Paperboy saved the filter and sent a confirmation link. Nothing will be delivered until you confirm the address."; document.getElementById("unsubscribe-subscription").hidden = isUnsubscribed || !activeUnsubscribeUrl; document.getElementById("start-checkout").hidden = !canCheckout || !activeManagementToken || result.checkout_available === false; @@ -520,7 +530,7 @@ trackLifecycleOnce("purchase", "purchase", { billing_status: billingStatus, currency: details.currency || result.currency || "USD", - value: Number(details.value || result.value || 49), + value: Number(details.value || result.value || 5), transaction_id: details.transaction_id || result.transaction_id }); } @@ -686,16 +696,31 @@ var time = document.getElementById("delivery-time"); var timezone = document.getElementById("delivery-zone"); var intakeTimezone = document.getElementById("intake-timezone"); + var intakeCadence = document.getElementById("intake-cadence"); + var intakeWeeklyDay = document.getElementById("intake-weekly-day"); if (email && document.activeElement !== email) email.value = state.delivery.email || state.email; if (time && document.activeElement !== time) time.value = state.delivery.time; if (timezone) timezone.value = state.delivery.timezone; if (intakeTimezone && document.activeElement !== intakeTimezone) intakeTimezone.value = state.delivery.timezone || "UTC"; + if (intakeCadence) intakeCadence.value = state.delivery.cadence || "daily"; + if (intakeWeeklyDay) intakeWeeklyDay.value = String(state.delivery.weeklyDay || 0); + renderCadenceFields(); document.querySelectorAll('input[name="days"]').forEach(function (input) { input.checked = state.delivery.days.indexOf(input.value) >= 0; }); renderMiniEmail(); } + function renderCadenceFields() { + var cadence = document.getElementById("intake-cadence"); + var weeklyField = document.getElementById("weekly-day-field"); + var weeklyDay = document.getElementById("intake-weekly-day"); + if (!cadence || !weeklyField || !weeklyDay) return; + var isWeekly = cadence.value === "weekly"; + weeklyField.hidden = !isWeekly; + weeklyDay.disabled = !isWeekly; + } + function renderMiniEmail() { var container = document.getElementById("mini-email-content"); if (!container) return; @@ -927,6 +952,8 @@ var workFocus = document.getElementById("work-focus").value.trim(); var ignoreFocus = document.getElementById("ignore-focus").value.trim(); var timezone = document.getElementById("intake-timezone").value.trim(); + var cadence = document.getElementById("intake-cadence").value; + var weeklyDay = Number(document.getElementById("intake-weekly-day").value); var consent = document.getElementById("email-consent").checked; if (!isValidEmail(input.value.trim())) { error.textContent = "Enter a valid email address."; @@ -953,7 +980,7 @@ return; } if (!timezone) { - intakeError.textContent = "Confirm the time zone for the daily edition."; + intakeError.textContent = "Confirm the time zone for the rollup."; document.getElementById("intake-timezone").focus(); return; } @@ -967,6 +994,8 @@ state.email = email; state.delivery.email = state.email; state.delivery.timezone = timezone; + state.delivery.cadence = cadence; + state.delivery.weeklyDay = weeklyDay; saveState(); var subscribeResult; @@ -980,6 +1009,8 @@ focus: workFocus, ignore: ignoreFocus ? ignoreFocus.split(",").map(function (term) { return term.trim(); }).filter(Boolean) : [], timezone: timezone, + cadence: cadence, + weekly_day: weeklyDay, consent: true, analytics_consent: analyticsAllowed() }, { @@ -992,13 +1023,17 @@ throw new Error(subscriptionErrorMessage(subscribeResponse, subscribeResult)); } } catch (subscriptionError) { - intakeError.textContent = subscriptionError && subscriptionError.message ? subscriptionError.message : "Paperboy could not save the daily brief request. Try again."; + intakeError.textContent = subscriptionError && subscriptionError.message ? subscriptionError.message : "Paperboy could not save the rollup request. Try again."; submit.disabled = false; - submit.textContent = "Start my daily brief"; + submit.textContent = "Start my rollup"; return; } - trackProductEvent("subscription_requested", { source_count: sourceUrls.length }); + trackProductEvent("subscription_requested", { + source_count: sourceUrls.length, + cadence: cadence, + weekly_day: weeklyDay + }); document.getElementById("subscription-success-title").textContent = "Check your email."; document.getElementById("subscription-success-copy").textContent = "Paperboy saved your filter and sent a confirmation link. Nothing will be delivered until you confirm the address and finish checkout."; document.getElementById("subscription-status").textContent = "Awaiting confirmation"; @@ -1018,7 +1053,7 @@ document.getElementById("magic-link-form").hidden = false; var submit = document.getElementById("subscription-submit"); submit.disabled = false; - submit.textContent = "Start my daily brief"; + submit.textContent = "Start my rollup"; document.getElementById("source-urls").focus(); }); @@ -1065,7 +1100,7 @@ button.disabled = true; button.textContent = "Opening secure checkout…"; fallback.hidden = true; - trackProductEvent("begin_checkout", { currency: "USD", value: 49 }); + trackProductEvent("begin_checkout", { currency: "USD", value: 5 }); try { var response = await fetch("/api/billing/checkout", { method: "POST", @@ -1114,7 +1149,7 @@ document.getElementById("unsubscribe-subscription").addEventListener("click", function () { if (!activeUnsubscribeUrl) return; openConfirm( - "Stop this daily brief?", + "Stop this rollup?", "Paperboy will stop automatic delivery. Your existing source links are not deleted by this action.", "Unsubscribe", async function () { @@ -1130,7 +1165,7 @@ throw new Error(subscriptionErrorMessage(response, result)); } renderManagedSubscription(result); - toast("Daily delivery stopped."); + toast("Scheduled delivery stopped."); } catch (error) { document.getElementById("subscription-success-copy").textContent = error && error.message ? error.message @@ -1195,6 +1230,17 @@ }); }); + document.getElementById("intake-cadence").addEventListener("change", function (event) { + state.delivery.cadence = event.target.value === "weekly" ? "weekly" : "daily"; + saveState(); + renderCadenceFields(); + }); + + document.getElementById("intake-weekly-day").addEventListener("change", function (event) { + state.delivery.weeklyDay = Number(event.target.value); + saveState(); + }); + document.getElementById("source-forwarding").addEventListener("change", function (event) { state.sources.forwarding = event.target.checked; saveState(); @@ -1358,7 +1404,7 @@ document.getElementById("magic-link-form").hidden = true; document.getElementById("confirmation-panel").hidden = true; document.getElementById("magic-success").hidden = false; - document.getElementById("subscription-success-title").textContent = "Loading your daily brief…"; + document.getElementById("subscription-success-title").textContent = "Loading your rollup…"; document.getElementById("subscription-success-copy").textContent = "Paperboy is checking the current subscription status."; document.getElementById("subscription-status").textContent = "Checking"; document.getElementById("preview-results").hidden = true; @@ -1371,7 +1417,7 @@ try { await refreshManagedSubscription(activeStatusUrl); } catch (error) { - document.getElementById("subscription-success-title").textContent = "Paperboy could not open this daily brief."; + document.getElementById("subscription-success-title").textContent = "Paperboy could not open this rollup."; document.getElementById("subscription-success-copy").textContent = error && error.message ? error.message : "The management link may be invalid or expired."; diff --git a/product/check.mjs b/product/check.mjs index f24397f..e9b03c8 100644 --- a/product/check.mjs +++ b/product/check.mjs @@ -17,8 +17,11 @@ function requireText(source, text, label) { ["Build your firehose. Read only what matters.", "filtered-firehose promise"], ["Hacker News", "Hacker News source"], ["Any public RSS or Atom feed", "open feed boundary"], + ["Rollup frequency", "cadence control"], + ["Daily", "daily cadence option"], + ["Weekly", "weekly cadence option"], ["Try your filtered firehose for seven days.", "trial offer"], - ["$49", "$49 founding price"], + ["$5", "$5 founding price"], ["Card required for the seven-day trial", "card-required trial disclosure"], ["Stripe-hosted checkout", "hosted checkout boundary"] ].forEach(([text, label]) => requireText(html, text, label)); @@ -33,10 +36,10 @@ function requireText(source, text, label) { ].forEach(([text, label]) => requireText(js, text, label)); [ - ["https://paperboy.kaibuilds.com/", "canonical KaiBuilds URL"], + ["https://newpaperboy.com/", "canonical product URL"], ["Build your firehose. Read only what matters.", "cold-traffic promise"], ["Try your filtered firehose for seven days.", "card-required trial offer"], - ["Start my daily brief", "single subscription CTA"] + ["Start my rollup", "single subscription CTA"] ].forEach(([text, label]) => requireText(html, text, label)); [ @@ -48,6 +51,8 @@ function requireText(source, text, label) { ['parsed.hostname === "checkout.stripe.com"', "Stripe checkout URL allowlist"], ['fetch("/api/analytics/event"', "consented first-party analytics"], ["timezone: timezone", "browser timezone submission"], + ["cadence: cadence", "daily or weekly cadence submission"], + ["weekly_day: weeklyDay", "weekly delivery day submission"], ["consent: true", "email consent submission"], ["activeStatusUrl", "tokenized in-page management status"], ["activeUnsubscribeUrl", "tokenized unsubscribe action"], @@ -81,7 +86,7 @@ if (html.includes("We’ll email you to collect a few sources")) { failures.push("Stale follow-up-only intake copy found."); } -const primaryCtaMatches = html.match(/Start my daily brief/g) || []; +const primaryCtaMatches = html.match(/Start my rollup/g) || []; if (primaryCtaMatches.length < 4) failures.push("Primary automatic-subscription CTA is not repeated consistently."); const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]); @@ -107,7 +112,7 @@ if (js.includes('/api/firehose/preview')) { [ [privacy, "What Paperboy collects", "privacy data disclosure"], [privacy, "does not connect to Gmail", "privacy inbox boundary"], - [terms, "card-required seven-day trial followed by $49 per month", "terms billing disclosure"], + [terms, "card-required seven-day trial followed by $5 per month", "terms billing disclosure"], [terms, "as-available basis", "terms availability boundary"] ].forEach(([source, text, label]) => requireText(source, text, label)); @@ -138,4 +143,4 @@ console.log("- " + ids.length + " unique HTML ids"); console.log("- no remote UI assets or form actions"); console.log("- only expected same-origin lifecycle and consented analytics requests"); console.log("- pending verification, Stripe-hosted checkout, and legal boundaries present"); -console.log("- required Daily Brief sections and demo boundaries present"); +console.log("- required rollup sections and demo boundaries present"); diff --git a/product/index.html b/product/index.html index ded9b33..a8b039d 100644 --- a/product/index.html +++ b/product/index.html @@ -3,14 +3,14 @@ - + - + - - + +Add Hacker News, startup feeds, RSS, research, and other public sources. Paperboy ranks the stream against what you are building—and shows why each item made the cut.
01 Choose the firehose.
02 Define what deserves attention.
-03 Get the daily cut automatically.
+03 Get the rollup automatically.
How it works
-After you confirm the address and start the trial, your active filter refreshes the sources and sends the strongest matches every day.
+After you confirm the address and start the trial, your active filter refreshes the sources and sends the strongest matches daily or weekly.
Automatic, without inbox access
Paperboy works from public RSS and Atom feeds. It validates each URL, reads a bounded set of recent items, removes duplicates, and ranks the result against your filter. No Gmail connection or manual concierge step is required.
-