diff --git a/AGENTS.md b/AGENTS.md index 6ce6a79..3f82d14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ Next.js App Router in JavaScript, Tailwind CSS with shadcn/ui, Cloudflare D1 thr - **Storage**: models in `models/` (D1 in production, SQLite for tests and local scripts), images in the R2 bucket bound as `IMAGES`, or S3 behind CloudFront, or Vercel Blob (`lib/image-storage.js`), rate limits in Upstash Redis or memory (`lib/rate-limit.js`). - **Comments**: subscribers comment on the web issues with no account: a signed link (`lib/reader-links.js`, four kinds, each signed with a key of its own) starts a session for one newsletter (`lib/reader-session.js`), and every function checks the contact is still an active subscriber. A comment's text is parsed to parts and printed as text, never HTML (`lib/comment-body.js`). Readers act through `lib/comments.js`, the owner through `lib/comment-moderation.js` and the `Comments` actions; hiding and removing always carry a reason the author sees. Emails (`lib/comment-notify.js`) are one digest an hour at most to the owner and one reply email to an author, never one per comment. A reader's email never appears in anything public. - **Undo send**: the dashboard's Send (the `startSend` server action, `send_campaign` with its dashboard-only `holdForUndo`) puts a draft in the `held` status for the newsletter's `sendUndoSeconds` (`lib/send-hold-rules.js`, default 30) instead of queueing it (`lib/send-hold.js`), and a `release-held-send` job hands it to `queueCampaign` like any send. Release and `undo_campaign_send` are each one write on the same row (held and not yet claimed), so exactly one wins; the hourly cron releases a hold whose job never ran. API, MCP and approved agent sends are never held, and a scheduled send is unaffected. Limits are checked at hold time and again by `queueCampaign`. `BarSend` (in `LayoutDashboard`) is the countdown. +- **Briefings sending by itself**: `reviewMode: send_if_checks_pass` (`docs/BRIEFINGS-DESIGN.md`, Auto-send). Only the dashboard-only `set_briefing_auto_send` turns it on (with `confirm`, paid plans, a daily cap); `create_briefing` and `update_briefing` refuse the mode, and an agent's change to what a briefing writes from turns it off. The engine only makes drafts; `settleSend` in `lib/briefings-delivery.js` reads the briefing, plan, cap and `check_campaign` (with the claim check) again, then sends through `send_campaign` with `via: "briefing"` (no new send path), or asks for approval as an app named `briefing:` when `Newsletter.agentApproval` is on. A held run is a draft with `heldReason` and the owner's note says why; a sent one emails the owner a pause link (`lib/briefing-pause-links.js`, `/briefing-pause`). - **Ready-to-send check**: `check_campaign` (`lib/ready-check-data.js` gathers, `lib/ready-check.js` words each item as pass, warning, blocker or info; the review step's `PanelReady` and the approval card show it). A blocker is what the send itself refuses (`checkCampaignSend` and `getPresendChecks` give the words); a warning never stops a send. The links (`lib/link-check.js`, the `check-links` job, results in `LinkCheck`, kept for the same set of addresses) are one fetch of an address in an issue, held to `lib/link-check-rules.js`: public hosts, default ports, three redirects followed by hand and each checked. The claim check is the other (`docs/CLAIM-CHECK.md`: `lib/claim-check-rules.js` reads the issue's figures and words the report, `lib/claim-check.js` keeps it in `ClaimCheck` per `campaignContentHash`, the `check-claims` job runs `lib/claim-check-run.js` through the Briefings fetcher's guards): dead links, figures not on the pages their paragraph links to, figures with no source, code only. It never runs in a send; its findings are warnings unless the newsletter has `requireClaimCheck`, which `getSendBlocker` reads. - **Editing a sent issue's page**: `update_published_issue` (`lib/actions/published-issues.js`, class `write`) changes only the web version of a sent issue (title, subtitle, body, tags, search settings) in place, once its send is over; the emails that went out are built from the campaign at send time, so nothing already sent changes, but a later step or resend reads the new words. The page as it was goes into `CampaignVersion` (reason `edit`, the same 30) and `restore_campaign_version` works on a sent issue the same way. The RSS feed has no updated date: the entry keeps its `pubDate` and guid and carries the new content; the sitemap's lastmod follows `seo.updatedAt`. - **Content calendar**: issues and LinkedIn posts planned in one place (`docs/CONTENT-CALENDAR.md` has the model). `ContentItem` (channel `newsletter` or `linkedin`; copy, funnel stage, pillar, references, private notes, visuals as pointers to `Upload` records, a plan, review), `EditorialBatch` (with an optional recurring pattern that only `apply_editorial_pattern` uses) and `ContentRevision` (the last 50 states of an item's content), in `lib/content-items.js`, `lib/content-batches.js`, `lib/content-plan.js` and the rules in `lib/content-rules.js`; the actions are `lib/actions/content-items.js` and `content-batches.js`, the pages are under `app/(app)/campaigns/calendar`. Planning never sends, schedules or publishes: nothing in these files writes a campaign or queues a job, every planning action is class `write-draft` (reads are `read`, deletes `destructive`), and approving is `decide_content_review`, a `dashboardOnly` action, so no key can approve. Editorial state (`idea` to `delivered`) is apart from a campaign's delivery; a LinkedIn post is posted by hand and only recorded (`record_content_publication`, which needs an approved item and a LinkedIn address), and nothing may call it scheduled or published otherwise. A planned day is a `YYYY-MM-DD` plus an optional wall-clock time in an IANA zone, with its instant worked out by `zonedTimeToInstant` (`lib/time-zone.js`). An edit to the title, hook, body, call to action, destination, visuals or linked issue of an approved item takes the approval back (`changes_requested`, `editedAfterApproval`); a move does not. `externalId` makes creates and imports repeat safely (unique per account). `get_content_calendar` and `lib/calendar.js` are unchanged and leave drafts out; `get_content_plan` is the one that shows plans beside real deliveries. diff --git a/app/(app)/briefings/[id]/page.js b/app/(app)/briefings/[id]/page.js index 19bc957..3ff67dd 100644 --- a/app/(app)/briefings/[id]/page.js +++ b/app/(app)/briefings/[id]/page.js @@ -2,6 +2,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { History } from "lucide-react"; import BadgeStatus from "@/components/BadgeStatus"; +import CardBriefingAutoSend from "@/components/CardBriefingAutoSend"; import CardSettings from "@/components/CardSettings"; import FormBriefing from "@/components/FormBriefing"; import HeaderPage from "@/components/HeaderPage"; @@ -9,9 +10,13 @@ import StateEmpty from "@/components/StateEmpty"; import TimeLocal from "@/components/TimeLocal"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { runAction } from "@/lib/actions/run-action"; +import { scoped } from "@/lib/account-scope"; +import { AUTO_SEND } from "@/lib/briefing-rules"; +import { isOnFreeTier } from "@/lib/plans"; import { isValidObjectId } from "@/lib/docdb"; import { READER_LANGUAGES } from "@/lib/reader-language-copy"; import { getSelectedNewsletter, openNewsletter } from "@/lib/selected-newsletter"; +import Newsletter from "@/models/Newsletter"; export const metadata = { title: "Briefing" }; @@ -41,6 +46,7 @@ export default async function BriefingPage({ params }) { throw error; } openNewsletter(view, briefing.newsletterId, `/briefings/${id}`); + const newsletter = await Newsletter.findOne(scoped(account, { _id: briefing.newsletterId })).select({ agentApproval: 1 }).lean(); const [{ runs }, { keys }] = await Promise.all([ runAction("list_briefing_runs", { account, user, input: { id }, via: "dashboard" }), runAction("list_ai_keys", { account, user, input: {}, via: "dashboard" }), @@ -50,7 +56,18 @@ export default async function BriefingPage({ params }) { <> } />
- +
+ + +
{runs.length === 0 ? ( diff --git a/app/(app)/briefings/actions.js b/app/(app)/briefings/actions.js index 207ef7c..4bed62f 100644 --- a/app/(app)/briefings/actions.js +++ b/app/(app)/briefings/actions.js @@ -94,6 +94,18 @@ export async function updateBriefing(id, values) { return { ok: true }; } +// Sending by itself, on or off. Only the dashboard can turn it on, and only with the owner's confirmation (set_briefing_auto_send). +export async function setBriefingAutoSend(id, { enabled, confirm = false, dailyCap } = {}) { + const { failure } = await runDashboardAction("set_briefing_auto_send", { + id: String(id ?? ""), + enabled: enabled === true, + ...(enabled === true && { confirm: confirm === true, ...(dailyCap && { dailyCap: Number(dailyCap) }) }), + }); + if (failure) return toFormError(failure); + refresh(id); + return { ok: true }; +} + export async function deleteBriefing(id) { const { failure } = await runDashboardAction("delete_briefing", { id: String(id ?? "") }); if (failure) return toFormError(failure); diff --git a/app/(site)/docs/briefings-bot/page.js b/app/(site)/docs/briefings-bot/page.js index d0abd2f..db830d4 100644 --- a/app/(site)/docs/briefings-bot/page.js +++ b/app/(site)/docs/briefings-bot/page.js @@ -11,7 +11,7 @@ export default function BriefingsBotPage() { <> FlockStack-Briefings is the program that reads web pages for briefings: a FlockStack feature where a newsletter owner describes a - topic, and FlockStack finds the week's stories on it and writes a draft issue that links to each original page. + topic, and FlockStack finds the week's stories on it and writes an issue that links to each original page, kept as a draft or, if the owner turns that on, sent once its checks pass.
@@ -34,7 +34,7 @@ export default function BriefingsBotPage() {

It also opens the pages a FlockStack issue links to when its writer checks the issue before sending it: it looks for the issue's figures on the pages it cites and checks that its links load, at most 25 links and 12 pages per check, and keeps - nothing of the pages it read. + nothing of the pages it read. When the owner has let the briefing send by itself, the same check runs on the issue just before it would go out, and a failing one keeps it as a draft.

It reads at most 1 MB of a page, waits at most 8 seconds, makes at most 2 requests to one host at a time and at most 300 diff --git a/app/briefing-pause/[token]/page.js b/app/briefing-pause/[token]/page.js new file mode 100644 index 0000000..8b5e106 --- /dev/null +++ b/app/briefing-pause/[token]/page.js @@ -0,0 +1,70 @@ +import { Check, Link2Off, PauseCircle } from "lucide-react"; +import { confirmPause } from "@/app/briefing-pause/actions"; +import CardNotice from "@/components/CardNotice"; +import { Button } from "@/components/ui/button"; +import { scoped } from "@/lib/account-scope"; +import { isDeletionPending } from "@/lib/account-state"; +import { verifyPauseToken } from "@/lib/briefing-pause-links"; +import { connectDb } from "@/lib/db"; +import Account from "@/models/Account"; +import Briefing from "@/models/Briefing"; + +// The page the pause link in a briefing's note after an auto-send opens. Opening it pauses nothing (mail scanners and link previews +// open links); only its button does. The URL carries the token: keep it out of search results, caches and referrer headers. +export const dynamic = "force-dynamic"; +export const metadata = { + title: "Pause a briefing", + robots: { index: false, follow: false }, + referrer: "no-referrer", +}; + +// The token as it was signed; a broken escape (a link cut or mangled by a mail client) is simply not a link of ours. +function readToken(value) { + try { + return decodeURIComponent(String(value ?? "")); + } catch { + return ""; + } +} + +async function load(link) { + try { + await connectDb(); + const account = await Account.findById(link.accountId).lean(); + if (!account || isDeletionPending(account)) return null; + return await Briefing.findOne(scoped(account, { _id: link.briefingId })).select({ name: 1, status: 1 }).lean(); + } catch (error) { + console.error("[briefing-pause] Could not load:", error?.message); + return null; + } +} + +export default async function BriefingPausePage({ params, searchParams }) { + const [{ token }, query] = await Promise.all([params, searchParams]); + const signed = readToken(token); + const link = verifyPauseToken(signed); + const briefing = link ? await load(link) : null; + + return ( +

+ {!briefing ? ( + + It may be older than 14 days, or its briefing was deleted. Pause the briefing in the dashboard instead. + + ) : briefing.status === "paused" ? ( + + {briefing.name} makes nothing until you turn it back on in the dashboard. Issues it already sent stay sent. + + ) : ( + + {briefing.name} stops running on its schedule until you turn it back on. An issue already sent can't be taken back. + {query?.result === "busy" && Too many tries from here: wait a few minutes and press it again.} +
+ + +
+
+ )} +
+ ); +} diff --git a/app/briefing-pause/actions.js b/app/briefing-pause/actions.js new file mode 100644 index 0000000..ba92580 --- /dev/null +++ b/app/briefing-pause/actions.js @@ -0,0 +1,34 @@ +"use server"; + +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { isDeletionPending } from "@/lib/account-state"; +import { runAction } from "@/lib/actions/run-action"; +import { verifyPauseToken } from "@/lib/briefing-pause-links"; +import { connectDb } from "@/lib/db"; +import { getClientIp, isRateLimited } from "@/lib/rate-limit"; +import Account from "@/models/Account"; + +const PER_IP = { name: "briefing-pause-ip", limit: 30, window: "10 m" }; + +// The confirmation page's button: the token comes back in the form, the same one the page was opened with. Pausing a briefing that +// is already paused changes nothing, so a link can be pressed again. +export async function confirmPause(formData) { + const token = String(formData.get("token") ?? ""); + const page = `/briefing-pause/${encodeURIComponent(token)}`; + if (await isRateLimited({ ...PER_IP, key: getClientIp(await headers()) })) redirect(`${page}?result=busy`); + const link = verifyPauseToken(token); + if (!link) redirect(page); + let result = "gone"; + try { + await connectDb(); + const account = await Account.findById(link.accountId).lean(); + if (account && !isDeletionPending(account)) { + await runAction("pause_briefing", { account, input: { id: link.briefingId }, via: "briefing" }); + result = "paused"; + } + } catch (error) { + if (error?.code !== "not_found") console.error("[briefing-pause] Could not pause:", error?.message); + } + redirect(`${page}?result=${result}`); +} diff --git a/components/CardBriefingAutoSend.jsx b/components/CardBriefingAutoSend.jsx new file mode 100644 index 0000000..b057899 --- /dev/null +++ b/components/CardBriefingAutoSend.jsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { setBriefingAutoSend } from "@/app/(app)/briefings/actions"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/lib/toast"; + +// "Sending by itself" for a briefing. Dashboard only: no API key or connected app can turn it on. Turning it on asks for a tick that +// the owner understands issues go out with nobody reading them first. +export default function CardBriefingAutoSend({ briefingId, isOn, dailyCap, maxDailyCap, isFree, askBeforeAgentSends }) { + const [confirmed, setConfirmed] = useState(false); + const [cap, setCap] = useState(String(dailyCap ?? 1)); + const [isPending, startTransition] = useTransition(); + + function change(enabled) { + startTransition(async () => { + const result = await setBriefingAutoSend(briefingId, { enabled, confirm: confirmed, dailyCap: cap }); + if (result?.error) toast.error(result.error); + else { + setConfirmed(false); + toast.success(enabled ? "This briefing sends by itself when every check passes" : "Every issue waits as a draft again"); + } + }); + } + + return ( +
+
+

Sending by itself

+

+ {isOn + ? "Each scheduled run that passes every check is sent to your whole audience with nobody reading it first. Anything that fails a check, a thin run or one started by hand stays a draft, and the note you get says why. You are emailed after each send, with a link to pause." + : "Today every issue waits as a draft for you. Turn this on and a scheduled run that passes every check, including the claim check, is sent to your audience by itself."} +

+ {askBeforeAgentSends &&

This newsletter asks before an agent sends, so each send waits for you on the Approvals page.

} +
+ {isFree ? ( +

The Free plan keeps a briefing's issues as drafts. A paid plan can send them by itself.

+ ) : isOn ? ( +
+ On: up to {dailyCap} {dailyCap === 1 ? "issue" : "issues"} a day. + +
+ ) : ( +
+ + +
+ +
+
+ )} +
+ ); +} diff --git a/docs/BRIEFINGS-DESIGN.md b/docs/BRIEFINGS-DESIGN.md index 3e32dda..ff5331e 100644 --- a/docs/BRIEFINGS-DESIGN.md +++ b/docs/BRIEFINGS-DESIGN.md @@ -1,6 +1,6 @@ # Briefings: design note -Status: built and deployed in the US region (2026-10-29); the EU briefings Worker is not provisioned yet. Only `reviewMode: draft` runs: sending by itself once every check passes (Phase 3) is not built. The note changes when the build shows it is wrong. +Status: built. Phases 1 and 2 (drafts only) run in production in the US region and, since 2026-10-31, in the EU region too: the EU briefings Worker exists and is enabled. Phase 3, sending by itself (`reviewMode: send_if_checks_pass`), is built and described under "Auto-send" below; it needs a deploy of the API Worker first (it carries the new delivery), then the app, then the briefings Worker, in each region. The note changes when the build shows it is wrong. A briefing belongs to a newsletter. From a topic brief (no fixed source list), it searches the open web on a schedule, picks the stories its audience cares about, reads the original pages, writes a short sourced issue in the newsletter's style, checks the issue, and saves it as a draft (or, when the account allows, sends it once every check passes). Each account brings its own AI provider key, and optionally its own search provider key, so the cost is the account's; model spend is capped per briefing per run, and discovery runs on free indexes and free tiers. @@ -45,7 +45,7 @@ Seven collections and one migration (`0049_briefings.sql`, additive). All are ac - `steps`: `{ query, triage, extract, write, check, translate }`, each `{ keyId, model }`; `translate` is needed only when `languages` has more than one. The check step must use a different model from the write step; saving the same model is refused. - `prices`: optional per-model `{ inputPerMillion, outputPerMillion }` in USD, overriding the built-in table - `budgetUsd`: the model spend cap per run (default 0.25, at most 20), with optional per-step shares `stepBudgets: { query, triage, extract, write, check, translate }` (defaults 5, 10, 35, 20, 15, 15 percent). Discovery has its own `discoveryBudgetUsd`, 0 by default because the default indexes and free tiers cost nothing. -- `reviewMode`: `draft` or `send_if_checks_pass` (Phase 3; until then only `draft` is accepted) +- `reviewMode`: `draft` or `send_if_checks_pass` (see "Auto-send"); with the second, `autoSendDailyCap` (1 to 3, default 1) and who confirmed it and when (`autoSendConfirmedBy`, `autoSendConfirmedAt`) - `testEmail`: the address the test send goes to, which must be a team member's address **`BriefingRun`** (one per briefing per slot; the unique `(briefingId, slot)` index is the lock, as `SeriesRun` is for series) @@ -55,7 +55,7 @@ Seven collections and one migration (`0049_briefings.sql`, additive). All are ac - `stories[]`: `{ externalId, title, url, score, sources[] }` - `calls[]`: `{ step, provider, model, inputTokens, outputTokens, costUsd, costFrom: "provider" | "price_table" }`, and `costUsd` (the total) - `stories[]` also carry `kind` (`full` or `brief`) and `sourceKind` (`reporting`, `press_release`, `filing`, `regulator`, `company`, `community`, `other`). -- `thin`, `heldReason` (`review_mode`, `thin`, `checks`, `partial`, `agent_approval`), `languages[]` (`{ language, status }`) +- `thin`, `heldReason` (`review_mode`, `thin`, `checks`, `partial`, `agent_approval`, `daily_cap`), `autoSentAt` (when delivery queued the issue for sending by itself, which the daily cap counts), `languages[]` (`{ language, status }`) - `checks[]`: `{ id, status, message }`, `campaignId`, `error`, `startedAt`, `finishedAt` - Working data between stages (`items`, `records`, `draft`) is kept on the run while it is running and dropped when it finishes. Runs are kept 90 days. @@ -75,10 +75,11 @@ A new `Briefings` group in the registry, so the dashboard, REST and MCP get the | - | - | - | | `list_briefings` | `GET /briefings` | read | | `get_briefing` | `GET /briefings/{id}` | read | -| `create_briefing` | `POST /briefings` | write (send with `send_if_checks_pass`) | -| `update_briefing` | `PATCH /briefings/{id}` | write (send when it turns on `send_if_checks_pass`) | +| `create_briefing` | `POST /briefings` | write (refuses `send_if_checks_pass`) | +| `update_briefing` | `PATCH /briefings/{id}` | write (refuses turning on `send_if_checks_pass`; an agent's change to what the briefing writes from turns it off) | +| `set_briefing_auto_send` | none: dashboard only | admin class (a `dashboardOnly` action, so no key, app, tool or route) | | `pause_briefing` | `POST /briefings/{id}/pause` | write | -| `resume_briefing` | `POST /briefings/{id}/resume` | write (send with `send_if_checks_pass`) | +| `resume_briefing` | `POST /briefings/{id}/resume` | write | | `delete_briefing` | `DELETE /briefings/{id}` | destructive | | `run_briefing` | `POST /briefings/{id}/run` | write; 6 an hour | | `list_briefing_runs` | `GET /briefings/{id}/runs` | read | @@ -120,7 +121,26 @@ Why its own Worker: the app handler has about 250 KB left in its size budget, an 6. **Write.** One call to the write model with the style guide, the brief and the records only (no raw pages): full stories first, then the brief items as one line each with their link. It returns Markdown in which every paragraph cites at least one record by number; code turns the citations into links to the originals. 7. **Check.** Code checks first: every number in the draft appears in the text of a page it cites; every link resolves (the link checker's rules); every story links to its original; no quote is over 25 words; length within bounds. Then the check model, which must differ from the writer, judges each claim against the record it cites (with a TypeSafe key: one Noul per claim, as in TypeSafe's citation check). Each check is recorded as `pass`, `warning` or `blocker`. The same code checks of links and figures, without a briefing's records (the issue's own links are its sources), run on every issue as the ready-to-send check's claim check (docs/CLAIM-CHECK.md, `lib/claim-check-rules.js`), through this engine's fetcher. 8. **Translate** (beta). When `languages` has more than one, the translate model writes each other language from the checked draft, within its share of the budget. The translations are stored against the draft for readers who choose that language, labelled beta; one that does not fit the budget is skipped and noted. -9. **Deliver.** Through `BriefingsDelivery`: the issue as a draft campaign (`externalId` `briefing::`, so a retried run returns the same draft), one newsletter content item linked to it carrying every story's references (the calendar allows one newsletter item per issue, and per-story items would fill the 5,000-item cap within a year of daily runs; story keys stay on the run), a test email of the draft to `testEmail`, and a separate note to the owner with the run's checks, cost, discovery notes and a keep or drop link per story. The note is never part of the draft. With `send_if_checks_pass` (Phase 3), a run with no blocker and no warning is queued for sending, unless the newsletter has "ask me before an agent sends" on, which turns it into an approval request. A thin run, a run that stopped at the budget, a run with any blocker, and a run with a discovery provider that failed never send: they are held as a draft, with `heldReason` and the reason in the owner's email. +9. **Deliver.** Through `BriefingsDelivery`: the issue as a draft campaign (`externalId` `briefing::`, so a retried run returns the same draft), one newsletter content item linked to it carrying every story's references (the calendar allows one newsletter item per issue, and per-story items would fill the 5,000-item cap within a year of daily runs; story keys stay on the run), a test email of the draft to `testEmail`, and a separate note to the owner with the run's checks, cost, discovery notes and a keep or drop link per story. The note is never part of the draft. With `send_if_checks_pass` (Phase 3), delivery decides whether the draft it just made is sent, as set out under "Auto-send": a run that passes every condition is queued for sending, unless the newsletter has "ask me before an agent sends" on, which turns it into an approval request. A thin run, a run that stopped at the budget, a run with any blocker or warning, a run with a discovery provider that failed and a run with a translation missing never send: they are held as a draft, with `heldReason` and the reason in the owner's email. + +## Auto-send + +Decided and built 2026-10-31. The engine only ever makes drafts; sending is a second decision, taken in `lib/briefings-delivery.js` (`settleSend`) with the API Worker's reach, and made through the normal send path, never a new one. + +**How the owner opts in.** Only a signed-in person, in the dashboard: the briefing's page has a "Sending by itself" card with a tick that issues go to the whole audience unread, and the daily cap. It runs `set_briefing_auto_send`, a `dashboardOnly` action like `set_agent_approval`: it has no REST route, MCP tool or docs entry, `runAction` refuses it for any other `via`, and it needs `confirm: true`. `create_briefing` and `update_briefing` refuse `reviewMode: send_if_checks_pass` for everyone (the dashboard's form does not send the field), and accept `draft`, which turns it off. So no API key at any level, including `full`, and no connected app can switch it on; a drafts key could not run those actions at all. The action class of `create_briefing` and `update_briefing` no longer rises with `reviewMode` (that input is refused), only with a raised budget. A change an API key or app makes to what a briefing writes from (brief, hosts, discovery, style guide, languages, story counts, sections, minimum score, steps) while it sends by itself sets it back to `draft`: the owner confirmed those settings, not an agent's. Name, schedule, test address, budget and status changes keep it. The Free plan is draft only: the action refuses there, and delivery holds as `review_mode` after a downgrade. + +**What must hold for a send**, all of it, checked in this order. Anything else leaves the draft, the run `drafted` with the reason in `heldReason`, and the owner's note (the same one, titled "A briefing draft is ready to review") saying why: + +1. The engine's verdict (`holdReason`): not `thin`, not `partial` (a step, a provider or a translation that did not finish; `run.languages` all `done`), every code check and the different-model judge a `pass` (`checks`), and the review mode is `send_if_checks_pass` (else `review_mode`). +2. Delivery's own reading of the world now: the run is scheduled, not started by hand (`run_briefing` promises a draft; `review_mode`); the briefing exists, is `active` and is still in send mode (the owner may have paused or turned it off mid-run; `review_mode`); the account is not on the Free plan (`review_mode`). +3. The daily cap: at most `autoSendDailyCap` runs with `autoSentAt` since 00:00 UTC (`daily_cap`). +4. `check_campaign` on the draft, including its links and its claim check: no blocker and no warning, except the test email item, which says nothing about the issue (`checks`). While the links and the claim check are still being read (their jobs run in the background), delivery answers `checking`, makes no note, and the deliver stage asks again every 4 seconds with the same payload for up to 10 minutes (`READY_CHECK_WAIT_MS`), then delivers once more with `checksTimedOut`, which holds it as `checks`. The checks are kept for the issue's words, so asking again costs nothing. +5. The send itself: `send_campaign` through `runAction` with `via: "briefing"`, so `queueCampaign`, the plan's emails and subscribers, the Free plan's guards, `getSendBlocker` (including `requireClaimCheck`) and the postal address all apply. A refusal is a draft (`checks`), not an error; a campaign already past draft (a retry, or the owner's own send) counts as sent and is not sent again. +6. A newsletter that asks before an agent sends (`Newsletter.agentApproval`) turns step 5 into an approval request instead: the briefing asks as an app named `briefing:` (`ApprovalRequest` with `via: "briefing"`, `reason: "newsletter"`), the run is `drafted` with `agent_approval`, and approving on the Approvals page runs `send_campaign` once, for as long as the briefing exists. An approval is not counted by the cap until it sends. + +**After a send.** The run is `sent`, `autoSentAt` is set, and the owner's note ("A briefing issue was sent") carries the checks, the cost and a pause link (`lib/briefing-pause-links.js`): a signed link to `/briefing-pause/`, valid 14 days, which opens a confirmation page and pauses only when its button is pressed, so scanners and previews cannot. It is emailed only, never logged, and not single use because pausing twice changes nothing. There is no undo window: that belongs to the dashboard's Send, and API, MCP and approved sends are never held. The ready-to-send check, the cap and the opt-in are the guard instead. + +**What it deliberately does not do.** It does not send a run it started itself on a Free plan, resend after a failed send, send to a part of the audience, or change a campaign's audience: the campaign's own audience (all, by default) is what `send_campaign` uses. ## Sections @@ -230,7 +250,7 @@ Two adapters cover the text providers (OpenAI-compatible and Anthropic Messages) - Copyright: summaries in the newsletter's own words, quotes of at most 25 words, every story links to its original, no full articles stored after the run. - Keys: encrypted at rest, decrypted only inside the briefings Worker for the call, never in an action's answer, a log line, an error message, the activity log or an export. Removing a key deletes the ciphertext at once. - Prompts get the page text as data inside delimiters, and the instructions say text inside them is never an instruction. The check step and the code checks run on the result regardless. -- Sending: a briefing never sends while its review mode is `draft`; "ask me before an agent sends" wins over `send_if_checks_pass`. +- Sending: a briefing never sends while its review mode is `draft`; only the owner, in the dashboard, turns on `send_if_checks_pass`; "ask me before an agent sends" wins over it (see "Auto-send"). ## Open questions for the owner diff --git a/lib/action-scopes.js b/lib/action-scopes.js index 0ab52cf..9c8fc3c 100644 --- a/lib/action-scopes.js +++ b/lib/action-scopes.js @@ -195,7 +195,7 @@ const CLASSES = { "set_pledges", "cancel_pledge", "start_pledge_charging", "stop_pledge_charging", "create_offer", "update_offer", "delete_offer", "set_launch_banner", "set_gifts", "set_gift_links", "create_product", "update_product", "archive_product", "set_product_file", - "decide_approval", "set_agent_approval", "decide_content_review", + "decide_approval", "set_agent_approval", "decide_content_review", "set_briefing_auto_send", ], }; @@ -209,8 +209,9 @@ const ELEVATIONS = { create_page: (input) => (input.status === "published" ? "write" : "write-draft"), update_page: (input) => (input.status === "published" ? "write" : "write-draft"), create_series: (input) => (input.mode === "send" ? "send" : "write"), - // A briefing that sends once its checks pass (not accepted yet) sends by itself, and one allowed to spend more than the default - // of the account's own model money per run, or any paid search, spends what can't be taken back: both wait like a send. + // A briefing allowed to spend more than the default of the account's own model money per run, or any paid search, spends what + // can't be taken back: it waits like a send. (Sending by itself is not an input an agent can set: only the dashboard-only + // set_briefing_auto_send turns it on, and create_briefing and update_briefing refuse reviewMode send_if_checks_pass.) create_briefing: (input) => (isBriefingSend(input) ? "send" : "write"), update_briefing: (input) => (isBriefingSend(input) ? "send" : "write"), // A test email to the owner is part of drafting; one to addresses the agent names is an email to strangers. @@ -220,7 +221,7 @@ const ELEVATIONS = { // DEFAULT_BUDGET_USD and the default discoveryBudgetUsd (lib/briefing-rules.js), repeated here: this file imports nothing. const BRIEFING_DEFAULT_BUDGET_USD = 0.25; function isBriefingSend(input) { - return input.reviewMode === "send_if_checks_pass" || Number(input.budgetUsd ?? 0) > BRIEFING_DEFAULT_BUDGET_USD || Number(input.discovery?.discoveryBudgetUsd ?? 0) > 0; + return Number(input.budgetUsd ?? 0) > BRIEFING_DEFAULT_BUDGET_USD || Number(input.discovery?.discoveryBudgetUsd ?? 0) > 0; } export function getActionClass(name, input = {}) { @@ -231,7 +232,7 @@ export function getActionClass(name, input = {}) { // Whether a call of this action can wait for the owner's approval (its class, its input, or the size of the change): its MCP tool // and REST operation then have the approval_required answer next to the action's own. export function isGateable(name) { - const classes = [ACTION_CLASSES[name], getActionClass(name, { mode: "send", to: ["someone@example.com"], reviewMode: "send_if_checks_pass" })]; + const classes = [ACTION_CLASSES[name], getActionClass(name, { mode: "send", to: ["someone@example.com"], budgetUsd: 1_000 })]; return classes.some((className) => NEWSLETTER_APPROVAL_CLASSES.includes(className)) || Object.hasOwn(SCALE_COUNTS, name); } @@ -302,7 +303,7 @@ const MODEL_BY_ID = { "update_campaign_archive", "update_published_issue", "get_campaign_progress", "check_campaign", "preview_campaign_cleanup", "preview_campaign_recipients", "list_campaign_versions", "get_campaign_version", "restore_campaign_version", ], SeriesPlan: ["update_series", "delete_series", "run_series", "list_series_runs"], - Briefing: ["get_briefing", "update_briefing", "pause_briefing", "resume_briefing", "delete_briefing", "run_briefing", "list_briefing_runs"], + Briefing: ["get_briefing", "update_briefing", "set_briefing_auto_send", "pause_briefing", "resume_briefing", "delete_briefing", "run_briefing", "list_briefing_runs"], BriefingRun: ["get_briefing_run", "rate_briefing_story"], ContentItem: [ "get_content_item", "update_content_item", "delete_content_item", "set_content_slot", "attach_content_asset", "set_content_assets", "link_content_campaign", diff --git a/lib/actions/agent-safety.js b/lib/actions/agent-safety.js index f79ce80..661a1ee 100644 --- a/lib/actions/agent-safety.js +++ b/lib/actions/agent-safety.js @@ -35,7 +35,7 @@ const ApprovalSchema = z.object({ preview: PreviewSchema.nullable(), newsletterId: z.string().nullable(), actor: ActorSchema, - via: z.enum(["api", "mcp"]), + via: z.enum(["api", "mcp", "briefing"]), reason: z.enum(["key", "newsletter", "scale"]).describe("key: the key's level sends this to the owner. newsletter: the newsletter asks before an agent sends. scale: too many contacts at once."), createdAt: dateOutput, expiresAt: dateOutput, @@ -62,7 +62,7 @@ const ActivitySchema = z.object({ at: dateOutput, kind: z.enum(["call", "approval"]), actor: ActorSchema, - via: z.enum(["api", "mcp"]).nullable(), + via: z.enum(["api", "mcp", "briefing"]).nullable(), action: z.string(), className: z.string().nullable(), newsletterId: z.string().nullable(), diff --git a/lib/actions/briefings.js b/lib/actions/briefings.js index feac9b6..15a97c1 100644 --- a/lib/actions/briefings.js +++ b/lib/actions/briefings.js @@ -8,6 +8,7 @@ import { findOwnerEmail } from "@/lib/accounts"; import { ALL_KEY_PROVIDERS, addAiKey, checkAiKey, deleteAiKey, isSearchProvider, listAiKeys } from "@/lib/ai-keys"; import { AI_KEY_STATUSES, + AUTO_SEND, BRIEFING_REVIEW_MODES, BRIEFING_RUN_STATUSES, BRIEFING_STAGES, @@ -63,7 +64,7 @@ import BriefingVerdict from "@/models/BriefingVerdict"; import TeamSeat from "@/models/TeamSeat"; const GROUP = "Briefings"; -const HOW_IT_WORKS = `A briefing belongs to a newsletter. There is no list of sources: from its brief (a topic, the audience, what makes a story useful and example stories) it searches the open web on its schedule, with free indexes and the account's own search key if it has one, picks the stories its audience cares about, reads the original pages, writes a short issue in the newsletter's style with every paragraph linked to its sources, checks the issue and saves it as a draft campaign for review, with a test email to testEmail and a note to the owner with a keep or drop link per story. Each step (query, triage, extract, write, check, translate) runs on a model of the account's own AI key (add_ai_key), so the model cost is the account's, capped by budgetUsd per run and shared between the steps by stepBudgets. A briefing never sends by itself: reviewMode draft is the only mode for now. On the Free plan a briefing runs once a week: its schedule has one day, and it has one manual run a week.`; +const HOW_IT_WORKS = `A briefing belongs to a newsletter. There is no list of sources: from its brief (a topic, the audience, what makes a story useful and example stories) it searches the open web on its schedule, with free indexes and the account's own search key if it has one, picks the stories its audience cares about, reads the original pages, writes a short issue in the newsletter's style with every paragraph linked to its sources, checks the issue and saves it as a draft campaign for review, with a test email to testEmail and a note to the owner with a keep or drop link per story. Each step (query, triage, extract, write, check, translate) runs on a model of the account's own AI key (add_ai_key), so the model cost is the account's, capped by budgetUsd per run and shared between the steps by stepBudgets. A briefing keeps each issue as a draft (reviewMode draft) unless its owner turns on sending by itself (reviewMode send_if_checks_pass) in the dashboard: only a signed-in person can, with a confirmation, on a paid plan (set_briefing_auto_send, which no API key or app can run). Then a scheduled run whose checks all pass is sent to the newsletter's audience through the normal send, at most once a day by default, and the owner is emailed after each one with a link to pause the briefing; any check that fails, a thin or unfinished run, a run started by hand, or a newsletter that asks before an agent sends (which makes it an approval request) leaves a draft, with heldReason on the run saying why. A change to what the briefing writes made through the API or MCP turns sending by itself back off. On the Free plan a briefing runs once a week: its schedule has one day, and it has one manual run a week.`; // query, extract, write and translate write text; TypeSafe answers judgments, so it can only triage and check. const JUDGMENT_STEPS = ["triage", "check"]; const HOST_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/; @@ -208,7 +209,9 @@ const BriefingSchema = z.object({ stepBudgets: z.object(Object.fromEntries(BRIEFING_STEPS.map((step) => [step, z.number()]))).describe("Each step's share of budgetUsd, in percent"), prices: z.array(z.object({ model: z.string(), inputPerMillion: z.number().nullable(), outputPerMillion: z.number().nullable() })), budgetUsd: z.number(), - reviewMode: z.enum(BRIEFING_REVIEW_MODES), + reviewMode: z.enum(BRIEFING_REVIEW_MODES).describe("draft: each issue waits as a draft. send_if_checks_pass: a run whose checks all pass is sent by itself (set in the dashboard with set_briefing_auto_send)"), + autoSendDailyCap: z.number().int().nullable().describe("With send_if_checks_pass: the most issues it sends in a UTC day; null otherwise"), + autoSendConfirmedAt: dateOutput.describe("When the owner confirmed sending by itself in the dashboard; null in draft mode"), testEmail: z.string().nullable(), nextRunAt: dateOutput.describe("When it is due next; null while paused"), manualRunAt: dateOutput.describe("A run asked for with run_briefing, waiting for the engine"), @@ -306,6 +309,8 @@ function toSchedule(schedule) { return { days: schedule?.days?.length ? [...schedule.days] : [...DEFAULT_SCHEDULE.days], time: schedule?.time ?? DEFAULT_SCHEDULE.time, timeZone: schedule?.timeZone || "UTC" }; } +const autoSendCapOf = (briefing) => Math.min(AUTO_SEND.maxDailyCap, Math.max(1, Number(briefing?.autoSendDailyCap) || AUTO_SEND.dailyCap)); + function toBriefing(briefing, keys, lastRun = null, verdicts = new Map()) { const settings = fromDocument(briefing); const { searchKeyId, candidateFeedKeyId, ...widths } = settings.discovery; @@ -330,6 +335,8 @@ function toBriefing(briefing, keys, lastRun = null, verdicts = new Map()) { prices: settings.prices.map(({ model, inputPerMillion, outputPerMillion }) => ({ model, inputPerMillion: inputPerMillion ?? null, outputPerMillion: outputPerMillion ?? null })), budgetUsd: settings.budgetUsd, reviewMode: settings.reviewMode, + autoSendDailyCap: settings.reviewMode === "send_if_checks_pass" ? autoSendCapOf(briefing) : null, + autoSendConfirmedAt: settings.reviewMode === "send_if_checks_pass" ? toIso(briefing.autoSendConfirmedAt) : null, testEmail: settings.testEmail, nextRunAt: toIso(briefing.nextRunAt), manualRunAt: toIso(briefing.manualRunAt), @@ -515,7 +522,6 @@ function getBriefingProblem(settings, keys, teamEmails) { if (sectionsProblem) return sectionsProblem; const shares = BRIEFING_STEPS.reduce((total, step) => total + settings.stepBudgets[step], 0); if (Math.abs(shares - 100) > 1e-9) return `The step budgets are shares of budgetUsd in percent and add up to 100, not ${shares}`; - if (settings.reviewMode !== "draft") return "Sending by itself once every check passes isn't available yet: keep reviewMode draft, and each issue waits for your review"; const keysProblem = getKeysProblem(settings, keys); if (keysProblem) return keysProblem; if (settings.status === "active") { @@ -658,10 +664,17 @@ const fields = { .max(20) .describe("Prices for models the built-in price table doesn't have, or to override it. A model with no known price can't run under the budget."), budgetUsd: z.number().positive().max(MAX_BUDGET_USD).describe(`The most a run may spend on model calls, in USD: ${DEFAULT_BUDGET_USD} by default, ${MAX_BUDGET_USD} at most. A call that would pass it isn't made.`), - reviewMode: z.enum(BRIEFING_REVIEW_MODES).describe("draft (the only mode for now): each issue waits as a draft for review. send_if_checks_pass isn't available yet."), + reviewMode: z + .enum(BRIEFING_REVIEW_MODES) + .describe("draft: each issue waits as a draft for review. send_if_checks_pass is turned on by the owner in the dashboard (set_briefing_auto_send), never through here: sending it here is refused, and draft turns it off."), testEmail: z.string().trim().max(254).nullable().describe("Where the test email of each run goes: the owner's or a team member's address. Default: yours."), }; +// What a briefing writes from: a change to any of these through the API or MCP turns sending by itself back off. +const CONTENT_FIELDS = ["brief", "trustedOutlets", "blockedHosts", "discovery", "styleGuide", "languages", "stories", "sections", "minScore", "steps"]; +const AUTO_SEND_ELSEWHERE = "Sending by itself is turned on by the owner in the dashboard (the briefing's Sending by itself card), not through the API or an agent: keep reviewMode draft here"; +const AUTO_SEND_FIELDS_UNSET = { autoSendDailyCap: 1, autoSendConfirmedAt: 1, autoSendConfirmedBy: 1 }; + const SETTING_FIELDS = ["status", "brief", "trustedOutlets", "blockedHosts", "discovery", "schedule", "styleGuide", "languages", "stories", "sections", "minScore", "steps", "stepBudgets", "prices", "budgetUsd", "reviewMode", "testEmail"]; const optionalSettings = Object.fromEntries(SETTING_FIELDS.map((name) => [name, fields[name].optional()])); @@ -710,6 +723,7 @@ const createBriefing = defineAction({ handler: async ({ account, input, user }) => { const newsletter = await findNewsletter(account, input.newsletter, { _id: 1 }); const { newsletter: _reference, ...changes } = input; + if (changes.reviewMode === "send_if_checks_pass") throw actionError("validation_failed", AUTO_SEND_ELSEWHERE); const settings = toSettings(null, changes); if (!settings.testEmail) settings.testEmail = (await findTestRecipient({ account, user }))?.toLowerCase() ?? null; const keys = await checkSettings(account, settings, { checkEmail: changes.testEmail !== undefined }); @@ -741,15 +755,19 @@ const updateBriefing = defineAction({ ...optionalSettings, }), output: BriefingSchema, - handler: async ({ account, input }) => { + handler: async ({ account, input, via }) => { const current = await findBriefing(account, input.id); const { id: _id, ...changes } = input; + const wasSending = current.reviewMode === "send_if_checks_pass"; + if (changes.reviewMode === "send_if_checks_pass" && !wasSending) throw actionError("validation_failed", AUTO_SEND_ELSEWHERE); const settings = toSettings(current, changes); + // What an agent changes in what the briefing writes was not what the owner confirmed sending by itself for. + if (wasSending && via !== "dashboard" && CONTENT_FIELDS.some((field) => changes[field] !== undefined)) settings.reviewMode = "draft"; const keys = await checkSettings(account, settings, { checkEmail: changes.testEmail !== undefined && settings.testEmail !== current.testEmail }); const document = toDocument(settings); const isActive = document.status === "active"; const rescheduled = changes.schedule !== undefined || current.status !== "active" || !current.nextRunAt; - const unset = { ...(!isActive && { nextRunAt: 1 }), ...(!document.testEmail && { testEmail: 1 }) }; + const unset = { ...(!isActive && { nextRunAt: 1 }), ...(!document.testEmail && { testEmail: 1 }), ...(document.reviewMode === "draft" && AUTO_SEND_FIELDS_UNSET) }; const { matchedCount } = await Briefing.updateOne(scoped(account, { _id: current._id }), { $set: { ...document, ...(isActive && rescheduled && { nextRunAt: nextBriefingRunAt(document.schedule) }) }, ...(Object.keys(unset).length > 0 && { $unset: unset }), @@ -759,6 +777,38 @@ const updateBriefing = defineAction({ }, }); +const setBriefingAutoSend = defineAction({ + name: "set_briefing_auto_send", + title: "Turn sending by itself on or off for a briefing", + group: GROUP, + description: `Turns a briefing's sending by itself on (reviewMode send_if_checks_pass) or off (draft). Only a signed-in person can, in the dashboard: no API key, connected app or MCP tool can, so an agent can never switch it on, and turning it on needs confirm: true, the owner's tick that every issue whose checks pass goes to the whole audience with nobody reading it first. A paid plan only. When on, a scheduled run is sent through the normal send (plan limits and the send checks apply) only if discovery found enough, every step and translation finished, every code check and the check model's judgment passed, and the ready-to-send check (with the claim check) has no blocker and no warning; at most dailyCap issues a UTC day (${AUTO_SEND.dailyCap} by default, ${AUTO_SEND.maxDailyCap} at most). A newsletter that asks before an agent sends turns each send into an approval request. After each send the owner is emailed with a link to pause the briefing.`, + http: { method: "PATCH", path: "/briefings/{id}/auto-send" }, + dashboardOnly: true, + idempotent: true, + example: { enabled: true, confirm: true, dailyCap: 1 }, + input: z.object({ + id: idInput("briefing"), + enabled: z.boolean().describe("true: send by itself when every check passes. false: keep every issue as a draft."), + confirm: z.boolean().optional().describe("Required with enabled true: the owner understands that issues go out with nobody reading them first, and that a send can't be taken back"), + dailyCap: z.number().int().min(1).max(AUTO_SEND.maxDailyCap).optional().describe(`With enabled true: the most issues it sends in a UTC day. ${AUTO_SEND.dailyCap} by default, ${AUTO_SEND.maxDailyCap} at most. A run past it is kept as a draft.`), + }), + output: BriefingSchema, + handler: async ({ account, input, user }) => { + if (!user?.id) throw actionError("forbidden", "Only a signed-in person can change this"); + const briefing = await findBriefing(account, input.id); + if (!input.enabled) { + await Briefing.updateOne(scoped(account, { _id: briefing._id }), { $set: { reviewMode: "draft" }, $unset: AUTO_SEND_FIELDS_UNSET }); + } else { + if (input.confirm !== true) throw actionError("validation_failed", "Confirm that issues will be sent to your whole audience with nobody reading them first: send confirm true"); + if (isOnFreeTier(account)) throw actionError("validation_failed", "The Free plan keeps a briefing's issues as drafts: upgrade to let it send by itself (change_plan)"); + await Briefing.updateOne(scoped(account, { _id: briefing._id }), { + $set: { reviewMode: "send_if_checks_pass", autoSendDailyCap: input.dailyCap ?? AUTO_SEND.dailyCap, autoSendConfirmedAt: new Date(), autoSendConfirmedBy: String(user.id) }, + }); + } + return showBriefing(account, await findBriefing(account, input.id)); + }, +}); + const pauseBriefing = defineAction({ name: "pause_briefing", title: "Pause a briefing", @@ -954,6 +1004,7 @@ export const briefingActions = [ getBriefing, createBriefing, updateBriefing, + setBriefingAutoSend, pauseBriefing, resumeBriefing, deleteBriefing, diff --git a/lib/actions/manifest.js b/lib/actions/manifest.js index fe0ca46..b4cd074 100644 --- a/lib/actions/manifest.js +++ b/lib/actions/manifest.js @@ -390,6 +390,7 @@ export const ACTION_MANIFEST = [ ["preview_agent_workspace","agents","POST","/agent/workspaces/preview","full",true], ["claim_agent_workspace","agents","POST","/agent/workspaces/claim","full",true], ["decide_content_review","content-items","POST","/content-items/{id}/decision","full",true], + ["set_briefing_auto_send","briefings","PATCH","/briefings/{id}/auto-send","full",true], ["open_campaign_collaboration","collaboration","POST","/campaigns/{id}/collaboration","full",true], ["checkpoint_campaign_collaboration","collaboration","POST","/campaigns/{id}/collaboration/checkpoint","full",true], ["approve_sponsorship_creative","sponsorships","POST","/sponsorships/bookings/{id}/approve","full",true], diff --git a/lib/approvals.js b/lib/approvals.js index e2a65c5..4432dbe 100644 --- a/lib/approvals.js +++ b/lib/approvals.js @@ -26,6 +26,9 @@ import ApprovalRequest from "@/models/ApprovalRequest"; import ApiKey from "@/models/ApiKey"; import Newsletter from "@/models/Newsletter"; +// A briefing's own sends ask as this kind of app (lib/briefings-delivery.js). +export const BRIEFING_ACTOR_PREFIX = "briefing:"; + // lib/webhooks.js reaches the sending code and the AWS clients: loaded when an event is sent, not with every call of the guard. const emitWebhookEvent = async (...args) => (await import("@/lib/webhooks")).emitWebhookEvent(...args); @@ -221,6 +224,13 @@ async function loadIdentity(account, approval) { if (!apiKey || apiKey.revokedAt || (apiKey.expiresAt && new Date(apiKey.expiresAt) <= new Date())) return null; return { apiKey }; } + // A briefing that sends by itself asks as an app named briefing:; it stands as long as the briefing does and still sends by itself. + if (approval.actorType === "app" && String(approval.actorId).startsWith(BRIEFING_ACTOR_PREFIX)) { + const id = String(approval.actorId).slice(BRIEFING_ACTOR_PREFIX.length); + const { default: Briefing } = await import("@/models/Briefing"); + const briefing = isObjectIdString(id) ? await Briefing.findOne(scoped(account, { _id: id })).select({ _id: 1 }).lean() : null; + return briefing ? {} : null; + } const { getConnection } = await import("@/lib/oauth"); const connection = await getConnection(account, approval.actorId); return connection ? { connection } : null; diff --git a/lib/briefing-pause-links.js b/lib/briefing-pause-links.js new file mode 100644 index 0000000..b845c33 --- /dev/null +++ b/lib/briefing-pause-links.js @@ -0,0 +1,36 @@ +import { isSignedWithAppKey, signWithAppKey } from "./app-keys.js"; +import { PAUSE_LINK_DAYS } from "./briefing-rules.js"; +import { isObjectIdString } from "./validation.js"; + +// The pause link in the note after a briefing sent an issue by itself (docs/BRIEFINGS-DESIGN.md, Auto-send): the briefing and the +// run it follows, signed with a key of its own from BETTER_AUTH_SECRET (lib/app-keys.js), for PAUSE_LINK_DAYS. It opens a +// confirmation page (app/briefing-pause) that pauses the briefing only when its button is pressed, so mail scanners and link previews +// cannot. Pausing twice is harmless, so unlike a verdict link it is not single use. base64url JSON + "." + HMAC. +const INFO = "flockstack-briefing-pause-v1"; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{1,400}\.[A-Za-z0-9_-]{43}$/; +export const PAUSE_LINK_MS = PAUSE_LINK_DAYS * 86_400_000; + +export function createPauseToken({ accountId, briefingId, runId }, now = Date.now()) { + if (![accountId, briefingId, runId].every((id) => isObjectIdString(String(id)))) throw new Error("A pause link needs an account, a briefing and a run"); + const data = { a: String(accountId), b: String(briefingId), r: String(runId), e: Math.floor((now + PAUSE_LINK_MS) / 1000) }; + const payload = Buffer.from(JSON.stringify(data)).toString("base64url"); + return `${payload}.${signWithAppKey(INFO, payload)}`; +} + +// { accountId, briefingId, runId, expiresAt }, or null for a link that is not one of ours, was changed or has expired. +export function verifyPauseToken(token, now = Date.now()) { + if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) return null; + const [payload, signature] = token.split("."); + if (!isSignedWithAppKey(INFO, payload, signature)) return null; + try { + const { a, b, r, e } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + if (![a, b, r].every((id) => isObjectIdString(id)) || !Number.isInteger(e) || e * 1000 < now) return null; + return { accountId: a, briefingId: b, runId: r, expiresAt: new Date(e * 1000) }; + } catch { + return null; + } +} + +export function getPauseLinkUrl(appUrl, token) { + return `${String(appUrl).replace(/\/+$/, "")}/briefing-pause/${token}`; +} diff --git a/lib/briefing-rules.js b/lib/briefing-rules.js index 9bdfd6a..b0593ef 100644 --- a/lib/briefing-rules.js +++ b/lib/briefing-rules.js @@ -16,8 +16,20 @@ export const BRIEFING_STEPS = ["query", "triage", "extract", "write", "check", " export const DEFAULT_STEP_BUDGETS = { query: 5, triage: 10, extract: 35, write: 20, check: 15, translate: 15 }; export const BRIEFING_STAGES = ["discover", "group", "triage", "extract", "write", "check", "deliver", "done"]; export const BRIEFING_RUN_STATUSES = ["running", "drafted", "sent", "no_news", "stopped_at_budget", "failed"]; -// Why a run that could have sent was held as a draft instead: a thin or failed run never sends. -export const HOLD_REASONS = ["review_mode", "thin", "checks", "partial", "agent_approval"]; +// Why a run that could have sent was held as a draft instead: a thin or failed run never sends. review_mode: the briefing keeps +// drafts (its mode, the Free plan, or auto-send turned off while the run was going). checks: a code check, the different-model judge +// or the ready-to-send check found something. partial: a step or a translation did not finish. daily_cap: the briefing already +// sent its allowance today. agent_approval: the newsletter asks before an agent sends, so the send waits on the Approvals page. +export const HOLD_REASONS = ["review_mode", "thin", "checks", "partial", "agent_approval", "daily_cap"]; +// Sending by itself (reviewMode send_if_checks_pass, docs/BRIEFINGS-DESIGN.md, Auto-send): issues a briefing may send in a UTC day, +// unless the owner sets another cap with set_briefing_auto_send, and the most that cap can be. +export const AUTO_SEND = { dailyCap: 1, maxDailyCap: 3 }; +// The pause link in the note after an auto-send works this long. +export const PAUSE_LINK_DAYS = 14; +// How long delivery waits for the ready-to-send check's links and claim check before it holds an issue as a draft, and how often +// the engine asks again meanwhile. +export const READY_CHECK_WAIT_MS = 10 * 60_000; +export const READY_CHECK_POLL_MS = 4_000; export const STORY_KINDS = ["full", "brief"]; export const SOURCE_KINDS = ["reporting", "press_release", "filing", "regulator", "company", "community", "other"]; export const VERDICTS = ["keep", "drop"]; @@ -53,7 +65,7 @@ export const ACCOUNT_FETCH_LIMITS = { hour: 600, day: 3000 }; // Runs asked for with run_briefing, per briefing per local day (lib/briefing-runs.js). export const MANUAL_RUNS_PER_DAY = 3; -// The Free plan's briefing (owner decision D8): one, as Free has one newsletter, drafts only as on every plan for now, and weekly. +// The Free plan's briefing (owner decision D8): one, as Free has one newsletter, drafts only (it never sends by itself), and weekly. // days: run days a week its schedule may have. scheduledGapDays: a scheduled run waits this long after the last scheduled one, so a // daily schedule kept from a paid plan runs weekly after a downgrade; a day short of a week, so a weekly slot that moves with // daylight saving still runs. manualGapDays: a manual run waits this long after the last manual one. diff --git a/lib/briefings-delivery.js b/lib/briefings-delivery.js index ff51a74..faef8af 100644 --- a/lib/briefings-delivery.js +++ b/lib/briefings-delivery.js @@ -1,23 +1,38 @@ import "server-only"; +import { summarizeCall } from "@/lib/agent-activity-rules"; import { scoped } from "@/lib/account-scope"; +import { loadAction } from "@/lib/actions/lazy"; import { runAction } from "@/lib/actions/run-action"; import { sendAlert } from "@/lib/alerts"; +import { BRIEFING_ACTOR_PREFIX, proposeApproval } from "@/lib/approvals"; +import { createPauseToken, getPauseLinkUrl } from "@/lib/briefing-pause-links"; +import { AUTO_SEND, READY_CHECK_WAIT_MS } from "@/lib/briefing-rules"; import { createVerdictToken, getVerdictLinkUrl } from "@/lib/briefing-verdict-links"; import { findOwnerEmail } from "@/lib/accounts"; import { getAppUrl } from "@/lib/config"; import { LIMITS } from "@/lib/content-rules"; import { connectDb } from "@/lib/db"; import { storeBriefingTranslations } from "@/lib/briefing-translations"; +import { isOnFreeTier } from "@/lib/plans"; import Account from "@/models/Account"; +import Briefing from "@/models/Briefing"; +import BriefingRun from "@/models/BriefingRun"; import Campaign from "@/models/Campaign"; +import Newsletter from "@/models/Newsletter"; import TeamSeat from "@/models/TeamSeat"; -// Step 9 of a briefing run (docs/BRIEFINGS-DESIGN.md, "The run"): the issue becomes a draft campaign, never a send, with its +// Step 9 of a briefing run (docs/BRIEFINGS-DESIGN.md, "The run"): the issue becomes a draft campaign, with its // translations kept for readers who choose them; the content calendar gets the issue with its sources; a test email goes to the // briefing's test address; and the owner gets a note with the checks, the cost and a keep or drop link per story. Everything goes through runAction, as the dashboard's // own writes do, with via "briefing". The briefings Worker reaches this through the API Worker's BriefingsDelivery entrypoint; a // self-hosted server's hourly cron calls it directly. Every step is safe to repeat: a run cut off mid-delivery is delivered again on // the next tick and finds what it already made. +// +// The one send in here is a briefing whose owner turned on sending by itself (docs/BRIEFINGS-DESIGN.md, Auto-send). The engine's +// own verdict (no thin run, no unfinished step, no check that is not a pass) is only the first condition: this module reads the +// briefing, the plan, the newsletter and the issue again, runs the ready-to-send check with its claim check, and sends through +// send_campaign, so the plan's limits, the Free plan's guards and the send blockers apply as for any send. Anything that fails +// leaves the draft, with the reason in heldReason and in the owner's note. const VIA = "briefing"; const SUBJECT_MAX = 200; const PREVIEW_MAX = 300; @@ -62,8 +77,11 @@ const HELD = { thin: "Kept as a draft: discovery found less than usual.", checks: "Kept as a draft: a check did not pass.", partial: "Kept as a draft: part of the run did not finish.", - agent_approval: "Kept as a draft: the newsletter asks before an agent sends.", + agent_approval: "Waiting for your approval: the newsletter asks before an agent sends, so it is on the Approvals page and nothing has gone out.", + daily_cap: "Kept as a draft: this briefing already sent its allowance of issues today.", }; +// The ready-to-send items that do not describe the issue: the test email is sent beside the draft, and is not a reason to hold it. +const NOT_ABOUT_THE_ISSUE = new Set(["test"]); // The keep and drop links of each story, to the confirmation page. They are plain links in a plain-text email: sendAlert sends text // only, through lib/platform-email.js, and nothing there rewrites links or counts clicks. @@ -95,10 +113,12 @@ export function describeSections(sections = []) { return lines; } -export function describeRun({ checks = [], costUsd = 0, testEmail, testResult, thin = false, heldReason = null, discoveryNotes = [], redirected = null, sections = [] }) { +export function describeRun({ checks = [], costUsd = 0, testEmail, testResult, thin = false, heldReason = null, holdDetail = [], sent = false, discoveryNotes = [], redirected = null, sections = [] }) { const count = (status) => checks.filter((check) => check.status === status).length; const lines = []; + if (sent) lines.push("Sent to your audience by itself, because every check passed. A send can't be taken back; the pause link below stops the next ones."); if (heldReason && HELD[heldReason]) lines.push(HELD[heldReason]); + for (const line of holdDetail.slice(0, 12)) lines.push(`- ${clip(line, 300)}`); if (thin) lines.push("This run was thin: discovery found fewer candidates than it needs for a full issue."); lines.push(`Checks: ${count("pass")} passed, ${count("warning")} ${count("warning") === 1 ? "warning" : "warnings"}, ${count("blocker")} ${count("blocker") === 1 ? "blocker" : "blockers"}.`); for (const check of checks.filter(({ status }) => status !== "pass")) lines.push(`${check.status === "blocker" ? "Blocker" : "Warning"}: ${clip(check.message, 300)}`); @@ -124,6 +144,11 @@ export function describeLinks(links = []) { return lines.join("\n"); } +// The pause link of the note after an auto-send, emailed only: it pauses the briefing without a sign-in. +export function describePause(url) { + return url ? `If this should not have gone out, pause the briefing so the next issue waits for you. The link opens a page that asks before it pauses anything:\n${url}` : ""; +} + // Where the test email goes: the briefing's address while it is still the owner's or an active team member's (checked when it was // saved, and again now, since people leave teams), else the owner's. redirected: the address it no longer goes to. async function findTestAddress(account, testEmail) { @@ -147,6 +172,78 @@ async function sendTest(account, campaignId, testEmail) { } } +const startOfUtcDay = (now) => new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + +// The ready-to-send items worth holding an issue for: every blocker and warning (a warning never stops a send by hand, but an +// issue nobody looks at gets the stricter reading), and the claim check not having run. The test email is sent beside the draft +// and says nothing about it. +export function findReadyProblems(check) { + const problems = (check?.items ?? []).filter(({ status, id }) => (status === "blocker" || status === "warning") && !NOT_ABOUT_THE_ISSUE.has(id)).map(({ title, message }) => `${title}: ${message}`); + if (check?.claims?.state === "error") problems.push("Claim check: it could not start (too many checks this hour), so the issue was not checked for dead links and figures with no source."); + return problems; +} + +const held = (heldReason, ...holdDetail) => ({ status: "drafted", heldReason, holdDetail }); + +// Whether a run that passed the engine's own checks may be sent, and the send. { status: "sent" | "drafted" | "checking", heldReason, +// holdDetail[] }. "checking": the links or the claim check are still being read, ask again (the checks are kept for the issue's +// words, so asking costs nothing). Every refusal is a draft, never an error. +async function settleSend({ account, campaign, payload }) { + if (payload.heldReason) return held(payload.heldReason); + if (payload.reviewMode !== "send_if_checks_pass") return held("review_mode"); + // A run the owner asked for by hand is theirs to look at (run_briefing: "a draft, never a send"). + if (String(payload.slot).startsWith("manual:")) return held("review_mode", "A run you start by hand is always kept as a draft."); + const briefing = await Briefing.findOne(scoped(account, { _id: payload.briefingId })).select({ name: 1, status: 1, reviewMode: 1, autoSendDailyCap: 1 }).lean(); + if (!briefing || briefing.reviewMode !== "send_if_checks_pass" || briefing.status !== "active") return held("review_mode", "Sending by itself was turned off, or the briefing paused, while this run was going."); + if (isOnFreeTier(account)) return held("review_mode", "The Free plan keeps a briefing's issues as drafts: a paid plan sends them by itself."); + + const current = await Campaign.findOne(scoped(account, { _id: campaign.id })).select({ status: 1 }).lean(); + if (current && current.status !== "draft") return { status: "sent", alreadySent: true }; + + const cap = Math.min(AUTO_SEND.maxDailyCap, Math.max(1, Number(briefing.autoSendDailyCap) || AUTO_SEND.dailyCap)); + const sentToday = await BriefingRun.countDocuments(scoped(account, { briefingId: briefing._id, autoSentAt: { $gte: startOfUtcDay(new Date()) } })); + if (sentToday >= cap) return held("daily_cap", `${sentToday === 1 ? "One issue" : `${sentToday} issues`} already went out by itself today (UTC), and this briefing's allowance is ${cap}.`); + + const check = await runAction("check_campaign", { account, input: { id: campaign.id }, via: VIA }); + if (check.isChecking && !payload.checksTimedOut) return { status: "checking" }; + const problems = findReadyProblems(check); + if (check.isChecking) problems.push(`The ready-to-send check (${check.checking.join(" and ")}) did not finish in ${READY_CHECK_WAIT_MS / 60_000} minutes.`); + if (check.blockers > 0 || problems.length > 0) return held("checks", ...problems); + + const newsletter = await Newsletter.findOne(scoped(account, { _id: payload.newsletterId })).select({ agentApproval: 1 }).lean(); + const input = { id: campaign.id }; + if (newsletter?.agentApproval) { + // An agent's send waits for the owner on the Approvals page when the newsletter asks (lib/agent-guard.js); a briefing sends as + // an app of its own name, and approving runs send_campaign once with this exact input. + const action = await loadAction("send_campaign"); + try { + await proposeApproval({ + account, + agent: { type: "app", id: `${BRIEFING_ACTOR_PREFIX}${briefing._id}`, name: `Briefing: ${briefing.name}`, preset: "send_with_approval" }, + action, + className: "send", + input, + newsletterId: payload.newsletterId, + via: VIA, + requestId: `briefing:${payload.runId}`, + reason: "newsletter", + summary: summarizeCall(action, input), + }); + } catch (error) { + return held("checks", `Asking for your approval failed: ${clip(error?.message ?? "it failed", 300)}`); + } + return held("agent_approval"); + } + try { + await runAction("send_campaign", { account, input, via: VIA }); + } catch (error) { + // Another send, or the owner's, got there first. + if (error?.code === "conflict") return { status: "sent", alreadySent: true }; + return held("checks", `The send was refused: ${clip(error?.message ?? "it failed", 300)}`); + } + return { status: "sent" }; +} + export async function deliverIssue(payload) { const account = await loadAccount(payload.accountId); const campaign = await runAction("create_campaign", { @@ -182,17 +279,25 @@ export async function deliverIssue(payload) { } const { to: testEmail, redirected } = await findTestAddress(account, payload.testEmail); const testResult = await sendTest(account, campaign.id, testEmail); - const links = /^[a-f0-9]{24}$/i.test(String(payload.runId)) ? verdictLinks({ accountId: account._id, runId: payload.runId, stories: payload.stories }) : []; + const outcome = await settleSend({ account, campaign, payload }); + // The checks are still being read: no word to the owner yet, the engine asks again with the same payload. + if (outcome.status === "checking") return { campaignId: campaign.id, status: "checking" }; + const sent = outcome.status === "sent"; + if (sent && !outcome.alreadySent) await BriefingRun.updateOne(scoped(account, { _id: payload.runId }), { $set: { autoSentAt: new Date() } }); + const isRun = /^[a-f0-9]{24}$/i.test(String(payload.runId)); + const links = isRun ? verdictLinks({ accountId: account._id, runId: payload.runId, stories: payload.stories }) : []; + const pause = sent && isRun ? getPauseLinkUrl(getAppUrl(), createPauseToken({ accountId: account._id, briefingId: payload.briefingId, runId: payload.runId })) : null; + const heldReason = outcome.heldReason ?? null; await sendAlert({ accountId: account._id, key: `briefing-run-${payload.runId}`, - title: "A briefing draft is ready to review", - detail: `"${clip(payload.subject, SUBJECT_MAX)}"\n${describeRun({ checks: payload.checks, costUsd: payload.costUsd, testEmail, testResult, thin: payload.thin, heldReason: payload.heldReason, discoveryNotes: payload.discoveryNotes ?? [], redirected, sections: payload.sections ?? [] })}`, - emailOnly: describeLinks(links), - path: `/campaigns/${campaign.id}`, + title: sent ? "A briefing issue was sent" : heldReason === "agent_approval" ? "A briefing issue is waiting for your approval" : "A briefing draft is ready to review", + detail: `"${clip(payload.subject, SUBJECT_MAX)}"\n${describeRun({ checks: payload.checks, costUsd: payload.costUsd, testEmail, testResult, thin: payload.thin, heldReason, holdDetail: outcome.holdDetail ?? [], sent, discoveryNotes: payload.discoveryNotes ?? [], redirected, sections: payload.sections ?? [] })}`, + emailOnly: [describePause(pause), describeLinks(links)].filter(Boolean).join("\n\n"), + path: heldReason === "agent_approval" ? "/approvals" : `/campaigns/${campaign.id}`, cooldownMinutes: RUN_NOTE_COOLDOWN_MINUTES, }); - return { campaignId: campaign.id }; + return { campaignId: campaign.id, status: sent ? "sent" : "drafted", ...(heldReason && { heldReason }) }; } // A one-line word to the owner about a run that made no draft (no news, the budget, a failure). It goes to the account owner's diff --git a/lib/briefings/stages.js b/lib/briefings/stages.js index 15a0c9e..0a01a60 100644 --- a/lib/briefings/stages.js +++ b/lib/briefings/stages.js @@ -1,5 +1,5 @@ import { returnAiRequest, takeAiRequest } from "../ai-usage.js"; -import { DEFAULT_MIN_SCORE, DEFAULT_STORIES, MAX_BRIEF_ITEMS, MAX_FULL_STORIES } from "../briefing-rules.js"; +import { DEFAULT_MIN_SCORE, DEFAULT_STORIES, MAX_BRIEF_ITEMS, MAX_FULL_STORIES, READY_CHECK_POLL_MS, READY_CHECK_WAIT_MS } from "../briefing-rules.js"; import { checkLinks, claimChecks, codeChecks, renderedLinks } from "./check.js"; import { dedupeItems, toItem } from "./dedupe.js"; import { discoverySettings, lookbackStart, providerFetcher, runDiscovery } from "./discovery/index.js"; @@ -337,7 +337,7 @@ export const STAGES = { ...(record.coverage ?? []).map(({ url }) => ({ label: hostOf(url), url, kind: "coverage" })), ], })); - const result = await ctx.delivery.deliverIssue({ + const payload = { accountId: String(run.accountId), newsletterId: String(run.newsletterId), briefingId: String(briefing._id), @@ -356,8 +356,23 @@ export const STAGES = { reviewMode: briefing.reviewMode ?? "draft", discoveryNotes: run.discoveryNotes.map(({ provider, problem, scope, until }) => ({ provider, problem, scope, until: until ? new Date(until).toISOString() : null })), sections: (run.sections ?? []).map(({ id, title, status, items, costUsd }) => ({ id, title, status, items, costUsd })), - }); - return { finish: result?.status === "sent" && !run.heldReason ? "sent" : "drafted", campaignId: result?.campaignId }; + }; + let result = await ctx.delivery.deliverIssue(payload); + // Delivery reads the ready-to-send check again for an issue about to be sent: while the links and the claim check are still + // being read it answers "checking", and the stage asks again, for a while, with the checks kept for the same words. + if (result?.status === "checking") { + work.checkingSince ??= ctx.clock().getTime(); + if (ctx.clock().getTime() - work.checkingSince < READY_CHECK_WAIT_MS) { + // Out of time in this tick: the next one asks again. + if (!ctx.canWait(READY_CHECK_POLL_MS)) return { yield: true }; + await ctx.sleep(READY_CHECK_POLL_MS); + return { partial: true }; + } + result = await ctx.delivery.deliverIssue({ ...payload, checksTimedOut: true }); + } + // Delivery decides the send, with its own reasons for holding an issue back: they replace the engine's. + if (result?.heldReason) run.heldReason = result.heldReason; + return { finish: result?.status === "sent" ? "sent" : "drafted", campaignId: result?.campaignId }; }, }; @@ -413,7 +428,8 @@ function firstSentence(text) { // A thin run, one where a provider failed or a step ran out of budget, and one with any blocker or warning never send. export function holdReason({ run, work, briefing }) { if (run.thin) return "thin"; - if (work.partial) return "partial"; + // A translation that is not done (no key, over its share of the budget, a provider that failed) is a step that did not finish. + if (work.partial || (run.languages ?? []).some(({ status }) => status !== "done")) return "partial"; if ((run.checks ?? []).some(({ status }) => status !== "pass")) return "checks"; if ((briefing.reviewMode ?? "draft") !== "send_if_checks_pass") return "review_mode"; return null; diff --git a/lib/docs/briefings-guide.js b/lib/docs/briefings-guide.js index 236b4f2..7f51185 100644 --- a/lib/docs/briefings-guide.js +++ b/lib/docs/briefings-guide.js @@ -1,5 +1,6 @@ import { BRIEFING_ISSUE_SECTIONS } from "@/lib/briefing-issue-pattern"; import { + AUTO_SEND, DEFAULT_BUDGET_USD, DEFAULT_DISCOVERY, DEFAULT_MIN_SCORE, @@ -19,6 +20,8 @@ import { MAX_STYLE_GUIDE_LENGTH, MAX_TOPIC_LENGTH, MAX_TRUSTED_OUTLETS, + PAUSE_LINK_DAYS, + READY_CHECK_WAIT_MS, SEEN_RETENTION_DAYS, VERDICT_LINK_DAYS, } from "@/lib/briefing-rules"; @@ -27,7 +30,7 @@ import { // paragraphs: plain text, with `code` for names and [label](/path) for a link. list: a bulleted list after the paragraphs. // code: an example, as JSON. export const BRIEFINGS_INTRO = - "A briefing turns a topic into a sourced draft issue on a schedule. You say what to look for and who reads it; it searches the web, picks the stories, reads the original pages, writes the issue in your newsletter's style, checks it and leaves it as a draft for you. An agent can set it up and look after it over MCP or the REST API, with the same rules as the dashboard."; + "A briefing turns a topic into a sourced draft issue on a schedule. You say what to look for and who reads it; it searches the web, picks the stories, reads the original pages, writes the issue in your newsletter's style, checks it and leaves it as a draft for you, or, if you turn that on, sends it when every check passes. An agent can set it up and look after it over MCP or the REST API, with the same rules as the dashboard."; const usd = (value) => `$${value.toFixed(2)}`; const STEP_SHARES = Object.entries(DEFAULT_STEP_BUDGETS) @@ -47,7 +50,7 @@ export const BRIEFINGS_GUIDE = [ "Writes the issue in your style guide's voice, with every paragraph and every line linked to its source.", "Checks the issue, then saves it as a draft campaign, sends a test email of it to your test address, and sends you a separate note with the checks, the cost and a keep or drop link per story.", ], - after: ["A briefing never sends by itself. Each issue waits as a draft until you, or an agent with a key allowed to send, sends it like any other campaign."], + after: ["Each issue waits as a draft until you, or an agent with a key allowed to send, sends it like any other campaign. Only you can let a briefing send by itself, in the dashboard (see Sending by itself, below)."], }, { title: "Set the brief", @@ -162,10 +165,30 @@ export const BRIEFINGS_GUIDE = [ "The check model's verdict on each claim: one its source contradicts or doesn't support is a blocker or a warning.", ], after: [ - "Every issue waits as a draft for now: sending by itself once every check passes isn't available yet (`reviewMode` is `draft`). Each run says why its draft was held in `heldReason`: `review_mode` (drafts only), `checks` (a check didn't pass), `thin` (discovery found fewer stories than an issue needs), `partial` (part of the run didn't finish, such as a provider that failed) or `agent_approval` (the newsletter asks before an agent sends). A run that stopped at the budget or failed makes no draft.", + "Every issue waits as a draft unless you turn on sending by itself (`reviewMode` is `draft` by default). Each run says why its draft was held in `heldReason`: `review_mode` (drafts only, or sending by itself doesn't apply to this run), `checks` (a check didn't pass), `thin` (discovery found fewer stories than an issue needs), `partial` (part of the run didn't finish, such as a provider that failed, or a translation that wasn't written), `daily_cap` (the briefing already sent its allowance today) or `agent_approval` (the newsletter asks before an agent sends, so the send waits on the Approvals page). A run that stopped at the budget or failed makes no draft.", "`check_campaign` runs the usual pre-send checks on the draft before you send it.", ], }, + { + title: "Sending by itself", + paragraphs: [ + "A briefing can send each issue by itself instead of leaving a draft (`reviewMode` `send_if_checks_pass`). It is off by default, and only you can turn it on: in the dashboard, on the briefing's page, under Sending by itself, with a tick that issues go to your whole audience with nobody reading them first. No API key, connected app or MCP tool can turn it on (`set_briefing_auto_send` is a dashboard action, and `create_briefing` and `update_briefing` refuse the mode), so an agent can never start it. It is for paid plans: the Free plan keeps every issue as a draft.", + "A scheduled run is sent only when all of this holds, and otherwise stays a draft with the reason in `heldReason` and in your note:", + ], + list: [ + "Discovery found enough stories (not `thin`), every step and every translation finished (not `partial`), and the run didn't stop at its budget.", + "Every code check and the check model's verdict passed: no blocker and no warning (`checks`).", + `The usual ready-to-send check, \`check_campaign\`, has no blocker and no warning on the draft, and that includes the claim check: links that load, and figures that are on the pages they link to. Keep subjects under 60 characters, add preview text, and give images alt text, or this will hold the issue. If the links and claim check are still being read after ${READY_CHECK_WAIT_MS / 60_000} minutes, the issue is held.`, + "The briefing is still active with sending by itself on when the issue is ready, so pausing it or turning it off while a run is going stops that run's send.", + `The briefing hasn't already sent its allowance for the day: ${AUTO_SEND.dailyCap} issue a UTC day by default, up to ${AUTO_SEND.maxDailyCap} if you set it (\`daily_cap\`).`, + "It isn't a run you started by hand with `run_briefing`: those are always drafts.", + ], + after: [ + "The send is the normal one (`send_campaign`): your plan's limits, the Free plan's guards and the sending checks apply, and a refusal leaves the draft. If the newsletter has \"ask me before an agent sends\" on, the send becomes an approval request on the Approvals page (`agent_approval`) and goes out only when you approve it.", + `After each send you get a note with the checks and the cost, and a pause link. It opens a page that asks before it pauses anything, so a mail scanner can't pause your briefing, and it works for ${PAUSE_LINK_DAYS} days. A send can't be taken back, and the dashboard's undo window doesn't apply to a briefing's send.`, + "If an API key or an agent changes what the briefing writes from (its brief, style guide, hosts, discovery, languages, story counts, sections, minimum score or steps), sending by itself turns off and issues are drafts again until you turn it back on in the dashboard. Changes to its name, schedule, test address and budget don't.", + ], + }, { title: "Languages (beta)", paragraphs: [ @@ -184,7 +207,7 @@ export const BRIEFINGS_GUIDE = [ { title: "Run it with an agent", paragraphs: [ - "Every briefing action is in the Briefings group of the API reference and is an MCP tool of the same name: `list_briefings`, `get_briefing`, `create_briefing`, `update_briefing`, `pause_briefing`, `resume_briefing`, `delete_briefing`, `run_briefing`, `list_briefing_runs`, `get_briefing_run`, `rate_briefing_story`, `add_ai_key`, `list_ai_keys`, `check_ai_key` and `delete_ai_key`.", + "Every briefing action is in the Briefings group of the API reference and is an MCP tool of the same name: `list_briefings`, `get_briefing`, `create_briefing`, `update_briefing`, `pause_briefing`, `resume_briefing`, `delete_briefing`, `run_briefing`, `list_briefing_runs`, `get_briefing_run`, `rate_briefing_story`, `add_ai_key`, `list_ai_keys`, `check_ai_key` and `delete_ai_key`. Turning on sending by itself is not among them: only you can, in the dashboard.", "`get_agent_brief` is the place to start each session: for each briefing it has the last runs (status, cost against the cap, stories per section, whether the draft was held and why, translations that failed), the next run, and `needsOwner`, the drafts waiting for review and the stories with no keep or drop.", "What a key can do: a read only key reads briefings and runs. A drafts only key also gives keep or drop. A send with approval key also creates and changes briefings, and its runs, deletes and budget raises wait for the owner. Adding and checking keys takes a full access key or the dashboard. A key limited to one newsletter reaches only that newsletter's briefing.", ], diff --git a/lib/marketing/analytics-privacy.js b/lib/marketing/analytics-privacy.js index 7130832..4d48d77 100644 --- a/lib/marketing/analytics-privacy.js +++ b/lib/marketing/analytics-privacy.js @@ -100,7 +100,7 @@ const PRIVATE_ATTRIBUTES = /^attr__(aria-label|title|value|alt|placeholder)$/; // second lock, for a page change inside the browser that leaves for one of them (tests/analytics-routes.test.mjs // keeps it in step with the layouts). // The changelog and the Custom plan form have no analytics: the privacy policy (ANALYTICS_RULE in lib/legal.js) names the pages that do. -const UNTRACKED_PATH = /^\/(admin|archive|reader|recommendations|unsubscribe|confirm|keep|poll|survey|preferences|referral|signup-flow|pledge|gift|gifts|give|offer|restore|briefing-verdict|oauth|api|mcp|p|o|c|shop|subscribe|embed|authors|perks|changelog|custom)(\/|$)/; +const UNTRACKED_PATH = /^\/(admin|archive|reader|recommendations|unsubscribe|confirm|keep|poll|survey|preferences|referral|signup-flow|pledge|gift|gifts|give|offer|restore|briefing-verdict|briefing-pause|oauth|api|mcp|p|o|c|shop|subscribe|embed|authors|perks|changelog|custom)(\/|$)/; export function isTrackedPath(pathname) { if (["/authors", "/authors/", "/perks", "/perks/"].includes(pathname)) return true; diff --git a/models/AgentActivity.js b/models/AgentActivity.js index 8562ed9..090c516 100644 --- a/models/AgentActivity.js +++ b/models/AgentActivity.js @@ -11,7 +11,7 @@ const agentActivitySchema = new mongoose.Schema( actorType: { type: String, enum: ["key", "app"], required: true }, actorId: { type: String, required: true }, actorName: { type: String, required: true }, - via: { type: String, enum: ["api", "mcp"] }, + via: { type: String, enum: ["api", "mcp", "briefing"] }, action: { type: String, required: true }, className: { type: String }, newsletterId: { type: mongoose.Schema.Types.ObjectId, ref: "Newsletter" }, diff --git a/models/ApprovalRequest.js b/models/ApprovalRequest.js index f45faa1..a894e5f 100644 --- a/models/ApprovalRequest.js +++ b/models/ApprovalRequest.js @@ -15,7 +15,8 @@ const approvalRequestSchema = new mongoose.Schema( actorId: { type: String, required: true }, actorName: { type: String, required: true }, preset: { type: String, required: true }, - via: { type: String, enum: ["api", "mcp"], required: true }, + // briefing: a briefing that sends by itself (lib/briefings-delivery.js), as an app named after the briefing. + via: { type: String, enum: ["api", "mcp", "briefing"], required: true }, action: { type: String, required: true }, className: { type: String, required: true }, // Why it wasn't run: the key's level, the newsletter's setting, or the size of the change. diff --git a/models/Briefing.js b/models/Briefing.js index 750473c..ead101c 100644 --- a/models/Briefing.js +++ b/models/Briefing.js @@ -69,6 +69,11 @@ const briefingSchema = new mongoose.Schema( prices: [{ _id: false, model: { type: String, required: true }, inputPerMillion: { type: Number }, outputPerMillion: { type: Number } }], budgetUsd: { type: Number, default: DEFAULT_BUDGET_USD }, reviewMode: { type: String, enum: BRIEFING_REVIEW_MODES, default: "draft" }, + // Set with reviewMode send_if_checks_pass, by the owner in the dashboard only (set_briefing_auto_send): who confirmed, when, and + // how many issues a UTC day it may send. + autoSendDailyCap: { type: Number }, + autoSendConfirmedAt: { type: Date }, + autoSendConfirmedBy: { type: String }, testEmail: { type: String, trim: true, lowercase: true }, nextRunAt: { type: Date }, manualRunAt: { type: Date }, diff --git a/models/BriefingRun.js b/models/BriefingRun.js index 06eba54..c68df40 100644 --- a/models/BriefingRun.js +++ b/models/BriefingRun.js @@ -29,6 +29,8 @@ const briefingRunSchema = new mongoose.Schema( thin: { type: Boolean, default: false }, // Set when the run made a draft where its review mode would have sent. heldReason: { type: String, enum: HOLD_REASONS }, + // When delivery queued this run's issue for sending by itself; the daily cap counts these. + autoSentAt: { type: Date }, campaignId: { type: mongoose.Schema.Types.ObjectId, ref: "Campaign" }, error: { type: String }, work: { type: mongoose.Schema.Types.Mixed }, diff --git a/proxy.js b/proxy.js index 765f32b..ae339c7 100644 --- a/proxy.js +++ b/proxy.js @@ -51,6 +51,8 @@ const PUBLIC_PATHS = [ /^\/keep$/, // The confirmation page of a briefing note's keep or drop link (lib/briefing-verdict-links.js): the token is the credential. /^\/briefing-verdict\/[A-Za-z0-9_.%-]+$/, + // The pause link after a briefing sent by itself (lib/briefing-pause-links.js), the same way. + /^\/briefing-pause\/[A-Za-z0-9_.%-]+$/, /^\/referral$/, /^\/signup-flow$/, /^\/reader\/(in|signin|replies)$/, diff --git a/tests/briefings-actions.test.mjs b/tests/briefings-actions.test.mjs index 880e482..1385eba 100644 --- a/tests/briefings-actions.test.mjs +++ b/tests/briefings-actions.test.mjs @@ -202,7 +202,7 @@ describe("managing a briefing", () => { [{ schedule: { time: "6am" } }, /time/], [{ languages: ["xx"] }, /languages/], [{ budgetUsd: 0 }, /budgetUsd/], - [{ reviewMode: "send_if_checks_pass" }, /isn't available yet/], + [{ reviewMode: "send_if_checks_pass" }, /turned on by the owner in the dashboard/], [{ testEmail: "stranger@example.org" }, /team/], [{ steps: { triage: { keyId: foreign.id, model: "gpt-4.1-mini" } } }, /isn't one of this account's/], [{ steps: { query: { keyId: search.id, model: "x" } } }, /needs a model key, not a search key/], @@ -547,6 +547,74 @@ describe("sections", () => { }); }); +describe("sending by itself", () => { + let user; + let briefing; + let runAction; + const dashboard = (name, input) => as(user, async () => runAction(name, { account: await m.Account.findById(user.accountId).lean(), user: { id: String(user.userId), email: user.email }, input, via: "dashboard" })); + + before(async () => { + runAction = (await import("../lib/actions/run-action.js")).runAction; + user = await t.customer("briefings-autosend@example.com"); + const openai = await addKey(user, "openai"); + briefing = (await t.api(user.key, "POST", "/briefings", { name: "Daily", brief: BRIEF, steps: allSteps(openai, openai) })).body; + }); + + test("no key or app turns it on: create and update refuse the mode, and the action that sets it is the dashboard's alone", async () => { + const create = await t.api(user.key, "POST", "/briefings", { name: "Other", newsletter: user.newsletterId, brief: BRIEF, reviewMode: "send_if_checks_pass" }); + assert.match(t.assertError(create, 400, "validation_failed"), /turned on by the owner in the dashboard/); + assert.match(t.assertError(await t.api(user.key, "PATCH", `/briefings/${briefing.id}`, { reviewMode: "send_if_checks_pass" }), 400, "validation_failed"), /dashboard/); + const response = await t.api(user.key, "PATCH", `/briefings/${briefing.id}/auto-send`, { enabled: true, confirm: true }); + assert.ok(response.status >= 400, "there is no REST route for it"); + await assert.rejects(runAction("set_briefing_auto_send", { account: await m.Account.findById(user.accountId).lean(), input: { id: briefing.id, enabled: true, confirm: true }, via: "api", apiKey: { _id: "a".repeat(24) } }), { code: "forbidden" }); + assert.equal((await m.Briefing.findOne({ accountId: user.accountId, _id: briefing.id }).lean()).reviewMode, "draft"); + const { ACTIONS } = await import("../lib/actions/registry.js"); + assert.ok(!ACTIONS.some(({ name }) => name === "set_briefing_auto_send"), "not a REST route, MCP tool or documented action"); + }); + + test("the dashboard turns it on only with the owner's confirmation, with a daily cap, and off again", async () => { + await assert.rejects(dashboard("set_briefing_auto_send", { id: briefing.id, enabled: true }), /Confirm that issues will be sent/); + await assert.rejects(dashboard("set_briefing_auto_send", { id: briefing.id, enabled: true, confirm: true, dailyCap: 4 }), { code: "validation_failed" }); + const on = await dashboard("set_briefing_auto_send", { id: briefing.id, enabled: true, confirm: true }); + assert.deepEqual([on.reviewMode, on.autoSendDailyCap], ["send_if_checks_pass", 1]); + assert.ok(on.autoSendConfirmedAt); + const stored = await m.Briefing.findOne({ accountId: user.accountId, _id: briefing.id }).lean(); + assert.equal(stored.autoSendConfirmedBy, String(user.userId)); + assert.equal((await dashboard("set_briefing_auto_send", { id: briefing.id, enabled: true, confirm: true, dailyCap: 3 })).autoSendDailyCap, 3); + assert.deepEqual((await t.api(user.key, "GET", `/briefings/${briefing.id}`)).body.reviewMode, "send_if_checks_pass", "the API shows it"); + const off = await as(user, () => m.actions.setBriefingAutoSend(briefing.id, { enabled: false })); + assert.deepEqual(off, { ok: true }); + const after = (await t.api(user.key, "GET", `/briefings/${briefing.id}`)).body; + assert.deepEqual([after.reviewMode, after.autoSendDailyCap, after.autoSendConfirmedAt], ["draft", null, null]); + assert.match((await as(user, () => m.actions.setBriefingAutoSend(briefing.id, { enabled: true }))).error, /Confirm that issues/); + }); + + test("what an agent changes in what the briefing writes turns it back off; its schedule, name and budget do not", async () => { + await dashboard("set_briefing_auto_send", { id: briefing.id, enabled: true, confirm: true }); + const patch = (body) => t.api(user.key, "PATCH", `/briefings/${briefing.id}`, body); + for (const body of [{ name: "Renamed" }, { schedule: { days: [1, 2] } }, { testEmail: user.email }, { budgetUsd: 0.2 }, { reviewMode: "send_if_checks_pass" }]) { + assert.equal((await patch(body)).body.reviewMode, "send_if_checks_pass", JSON.stringify(body)); + } + for (const body of [{ styleGuide: "Be bold" }, { brief: { topic: "Something else" } }, { trustedOutlets: ["example.com"] }]) { + await dashboard("set_briefing_auto_send", { id: briefing.id, enabled: true, confirm: true }); + const changed = await patch(body); + assert.equal(changed.status, 200, JSON.stringify(changed.body)); + assert.deepEqual([changed.body.reviewMode, changed.body.autoSendConfirmedAt], ["draft", null], JSON.stringify(body)); + } + await dashboard("set_briefing_auto_send", { id: briefing.id, enabled: true, confirm: true }); + const own = await dashboard("update_briefing", { id: briefing.id, styleGuide: "Mine" }); + assert.equal(own.reviewMode, "send_if_checks_pass", "the owner's own edits in the dashboard keep it"); + }); + + test("the Free plan keeps drafts: it can't be turned on", async () => { + const free = await t.customer("briefings-autosend-free@example.com"); + await m.Account.updateOne({ _id: free.accountId }, { $set: { subscriptionStatus: "canceled" } }); + const created = await t.api(free.key, "POST", "/briefings", { name: "Free daily", brief: BRIEF, schedule: { days: [3], time: "06:00", timeZone: "UTC" } }); + const account = await m.Account.findById(free.accountId).lean(); + await assert.rejects(as(free, () => runAction("set_briefing_auto_send", { account, user: { id: String(free.userId), email: free.email }, input: { id: created.body.id, enabled: true, confirm: true }, via: "dashboard" })), /Free plan keeps a briefing's issues as drafts/); + }); +}); + describe("agents", () => { test("each action has its class: reads, settings, drafting, deletes and keys", async () => { const names = ["list_briefings", "get_briefing", "create_briefing", "update_briefing", "pause_briefing", "resume_briefing", "delete_briefing", "run_briefing", "list_briefing_runs", "get_briefing_run", "rate_briefing_story", "add_ai_key", "list_ai_keys", "check_ai_key", "delete_ai_key"]; @@ -567,8 +635,10 @@ describe("agents", () => { check_ai_key: "admin", delete_ai_key: "destructive", }); - assert.equal(m.scopes.getActionClass("create_briefing", { reviewMode: "send_if_checks_pass" }), "send"); - assert.equal(m.scopes.getActionClass("update_briefing", { reviewMode: "send_if_checks_pass" }), "send"); + // Sending by itself is not an input an agent can set (create and update refuse it), so it does not raise the class. + assert.equal(m.scopes.getActionClass("create_briefing", { reviewMode: "send_if_checks_pass" }), "write"); + assert.equal(m.scopes.getActionClass("update_briefing", { reviewMode: "send_if_checks_pass" }), "write"); + assert.equal(m.scopes.getActionClass("set_briefing_auto_send", { enabled: true }), "admin", "and only a signed-in person could run the action that does"); assert.equal(m.scopes.isGateable("create_briefing"), true); assert.equal(m.scopes.getTargetOf("get_briefing_run").model, "BriefingRun"); assert.equal(m.scopes.getTargetOf("rate_briefing_story").model, "BriefingRun"); diff --git a/tests/briefings-auto-send.test.mjs b/tests/briefings-auto-send.test.mjs new file mode 100644 index 0000000..fe83c96 --- /dev/null +++ b/tests/briefings-auto-send.test.mjs @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { after, before, describe, test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { anonymous, as, catchRedirect, form, platformEmails, startTestApp } from "./helpers/app.mjs"; +import { QSTASH_KEYS, loadSendingModules, setupSender } from "./helpers/sending.mjs"; + +// Briefings sending by itself (docs/BRIEFINGS-DESIGN.md, Auto-send): lib/briefings-delivery.js sends a run's issue through +// send_campaign only when every condition holds, and leaves a draft with the reason (heldReason) and a note to the owner when one +// does not. A run reaches delivery already judged by the engine (thin, unfinished steps and failed checks arrive as payload.heldReason); +// delivery reads the briefing, the plan, the newsletter and the ready-to-send check again. +let app; +let m; +let delivery; +let owner; +let Briefing; +let BriefingRun; +let ApprovalRequest; +let pauseLinks; + +const hex = () => crypto.randomBytes(12).toString("hex"); +const SENT = ["sending", "sent"]; + +// A briefing in send mode with a run, and the payload the engine hands delivery for a clean run. Each scenario has its own briefing, +// so the daily cap of one does not count another's sends. +async function scenario({ briefing = {}, run = {}, payload = {} } = {}) { + const briefingId = hex(); + const runId = hex(); + await Briefing.create({ _id: briefingId, accountId: owner.accountId, newsletterId: owner.newsletterId, name: "Daily batteries", status: "active", reviewMode: "send_if_checks_pass", autoSendDailyCap: 1, ...briefing }); + await BriefingRun.create({ _id: runId, accountId: owner.accountId, briefingId, newsletterId: owner.newsletterId, slot: "2026-11-02", ...run }); + return { + briefingId, + runId, + payload: { + accountId: String(owner.accountId), + newsletterId: String(owner.newsletterId), + briefingId, + runId, + slot: "2026-11-02", + subject: "Batteries today", + previewText: "A short note", + markdown: "A plant opened in Ohio and it makes cells.", + stories: [{ storyKey: "a".repeat(64), kind: "full", sourceKind: "reporting", externalId: `briefing:${briefingId}:aaa`, title: "A plant opened in Ohio", hook: "It makes cells.", body: "The plant makes cells.", url: "https://news.example.com/ohio", references: [] }], + testEmail: owner.email, + checks: [{ id: "numbers", status: "pass", message: "Every number is in a cited page" }], + costUsd: 0.01, + thin: false, + heldReason: null, + reviewMode: "send_if_checks_pass", + discoveryNotes: [], + translations: [], + ...payload, + }, + }; +} + +const campaignOf = (id) => m.Campaign.findOne({ accountId: owner.accountId, _id: id }).lean(); +const notesSince = (count) => platformEmails.slice(count).filter(({ subject }) => /briefing issue|briefing draft/i.test(subject)); + +async function deliver(setup) { + const before = platformEmails.length; + const result = await delivery.deliverIssue(setup.payload); + const [note] = notesSince(before); + return { result, note, campaign: await campaignOf(result.campaignId), run: await BriefingRun.findOne({ accountId: owner.accountId, _id: setup.runId }).lean() }; +} + +before(async () => { + app = await startTestApp(QSTASH_KEYS); + m = { ...(await loadSendingModules()), approvals: await import("../app/(app)/approvals/actions.js"), pausePage: await import("../app/briefing-pause/[token]/page.js"), pauseActions: await import("../app/briefing-pause/actions.js") }; + delivery = await import("../lib/briefings-delivery.js"); + pauseLinks = await import("../lib/briefing-pause-links.js"); + Briefing = (await import("../models/Briefing.js")).default; + BriefingRun = (await import("../models/BriefingRun.js")).default; + ApprovalRequest = (await import("../models/ApprovalRequest.js")).default; + owner = await setupSender(m, "autosend-owner@example.com", { contacts: 3 }); +}); + +after(() => app?.stop()); + +describe("a run that passes everything", () => { + test("is queued for sending to the audience through send_campaign, counted against the daily cap, and the owner is told with a pause link", async () => { + const setup = await scenario(); + const { result, note, campaign, run } = await deliver(setup); + assert.equal(result.status, "sent"); + assert.equal(result.heldReason, undefined); + assert.ok(SENT.includes(campaign.status), campaign.status); + assert.ok(campaign.testSentAt, "the test email still goes to the test address"); + assert.ok(run.autoSentAt, "the run records the send, which the cap counts"); + assert.match(note.subject, /A briefing issue was sent/); + assert.match(note.text, /Sent to your audience by itself, because every check passed/); + assert.doesNotMatch(note.text, /Kept as a draft/); + const [, url] = note.text.match(/^(http\S+\/briefing-pause\/\S+)$/m); + assert.ok(!url.includes("/c/") && !url.includes("/o/"), "a plain link, never through click tracking"); + const link = pauseLinks.verifyPauseToken(url.split("/").at(-1)); + assert.deepEqual([link.briefingId, link.runId, link.accountId], [setup.briefingId, setup.runId, String(owner.accountId)]); + assert.ok(!JSON.stringify(await m.Campaign.find({ accountId: owner.accountId }).lean()).includes("briefing-pause"), "the link is in the note, not the issue"); + }); + + test("asking again after the send finds it sent and sends nothing twice", async () => { + const setup = await scenario(); + const first = await deliver(setup); + const emails = platformEmails.length; + const again = await delivery.deliverIssue(setup.payload); + assert.deepEqual([again.status, again.campaignId], ["sent", first.result.campaignId]); + assert.equal(await m.Campaign.countDocuments({ accountId: owner.accountId, externalId: `briefing:${setup.briefingId}:2026-11-02` }), 1); + assert.equal(platformEmails.length, emails, "the owner is not told twice"); + }); + + test("the pause link opens a page that pauses nothing until its button is pressed, and then pauses the briefing", async () => { + const setup = await scenario(); + const { note } = await deliver(setup); + const [, url] = note.text.match(/^(http\S+\/briefing-pause\/\S+)$/m); + const token = url.split("/").at(-1); + const render = async () => anonymous(async () => renderToStaticMarkup(await m.pausePage.default({ params: Promise.resolve({ token }), searchParams: Promise.resolve({}) }))); + assert.match(await render(), /Pause this briefing\?/); + assert.equal((await Briefing.findOne({ accountId: owner.accountId, _id: setup.briefingId }).lean()).status, "active", "opening it paused nothing"); + const pressed = await anonymous(() => catchRedirect(m.pauseActions.confirmPause(form({ token })))); + assert.equal(pressed, `/briefing-pause/${token}?result=paused`); + const paused = await Briefing.findOne({ accountId: owner.accountId, _id: setup.briefingId }).lean(); + assert.equal(paused.status, "paused"); + assert.equal(paused.nextRunAt, undefined); + assert.match(await render(), /Already paused/); + assert.equal(await anonymous(() => catchRedirect(m.pauseActions.confirmPause(form({ token: "nope" })))), "/briefing-pause/nope"); + assert.match(await anonymous(async () => renderToStaticMarkup(await m.pausePage.default({ params: Promise.resolve({ token: "%zz" }), searchParams: Promise.resolve({}) }))), /This link doesn't work/); + }); + + test("pause links are signed for this purpose, expire after 14 days and are not verdict links", async () => { + const ids = { accountId: hex(), briefingId: hex(), runId: hex() }; + const now = Date.now(); + const token = pauseLinks.createPauseToken(ids, now); + assert.deepEqual(pauseLinks.verifyPauseToken(token, now + 13 * 86_400_000) && { ...pauseLinks.verifyPauseToken(token, now) }.briefingId, ids.briefingId); + assert.equal(pauseLinks.verifyPauseToken(token, now + 15 * 86_400_000), null); + assert.equal(pauseLinks.verifyPauseToken(`${token.slice(0, -2)}xx`), null); + const verdict = (await import("../lib/briefing-verdict-links.js")).createVerdictToken({ accountId: ids.accountId, runId: ids.runId, storyKey: "a".repeat(10), verdict: "keep" }); + assert.equal(pauseLinks.verifyPauseToken(verdict), null); + assert.throws(() => pauseLinks.createPauseToken({ ...ids, briefingId: "x" }), /pause link needs/); + }); +}); + +describe("a run that is held as a draft", () => { + async function assertHeld(setup, reason, pattern) { + const { result, note, campaign, run } = await deliver(setup); + assert.deepEqual([result.status, result.heldReason], ["drafted", reason]); + assert.equal(campaign.status, "draft", "nothing is queued"); + assert.equal(run.autoSentAt, undefined); + assert.match(note.subject, /A briefing draft is ready to review|waiting for your approval/); + if (pattern) assert.match(note.text, pattern); + assert.doesNotMatch(note.text, /briefing-pause/); + return { result, note, campaign }; + } + + test("review_mode: the briefing keeps drafts", async () => { + const { note } = await assertHeld(await scenario({ payload: { reviewMode: "draft", heldReason: "review_mode" } }), "review_mode", /Kept as a draft for your review/); + assert.ok(note); + }); + + test("review_mode: sending by itself was turned off, or the briefing paused, while the run was going", async () => { + await assertHeld(await scenario({ briefing: { reviewMode: "draft" } }), "review_mode", /turned off, or the briefing paused/); + await assertHeld(await scenario({ briefing: { status: "paused" } }), "review_mode", /turned off, or the briefing paused/); + }); + + test("review_mode: a run started by hand is always a draft", async () => { + await assertHeld(await scenario({ payload: { slot: "manual:2026-11-02T08:00:00.000Z" } }), "review_mode", /you start by hand is always kept as a draft/); + }); + + test("review_mode: the Free plan never sends by itself", async () => { + const free = await setupSender(m, "autosend-free@example.com", { contacts: 2 }); + await m.Account.updateOne({ _id: free.accountId }, { $set: { subscriptionStatus: "canceled" } }); + const briefingId = hex(); + const runId = hex(); + await Briefing.create({ _id: briefingId, accountId: free.accountId, newsletterId: free.newsletterId, name: "Free daily", status: "active", reviewMode: "send_if_checks_pass" }); + await BriefingRun.create({ _id: runId, accountId: free.accountId, briefingId, newsletterId: free.newsletterId, slot: "2026-11-02" }); + const { payload } = await scenario(); + const result = await delivery.deliverIssue({ ...payload, accountId: String(free.accountId), newsletterId: String(free.newsletterId), briefingId, runId, testEmail: free.email }); + assert.deepEqual([result.status, result.heldReason], ["drafted", "review_mode"]); + assert.equal((await m.Campaign.findOne({ accountId: free.accountId, _id: result.campaignId }).lean()).status, "draft"); + }); + + test("thin, partial and checks from the engine arrive as the run's own reason and are never sent", async () => { + for (const [reason, pattern] of [["thin", /discovery found less than usual/], ["partial", /part of the run did not finish/], ["checks", /a check did not pass/]]) { + await assertHeld(await scenario({ payload: { heldReason: reason, thin: reason === "thin" } }), reason, pattern); + } + }); + + test("checks: the ready-to-send check's warning holds it, and the note names it", async () => { + const subject = "Batteries today: a plant opened in Ohio, prices fell again and a long subject keeps going"; + const { note } = await assertHeld(await scenario({ payload: { subject } }), "checks", /Kept as a draft: a check did not pass\./); + assert.match(note.text, /- Subject: .*characters/); + }); + + test("checks: the claim check holds an issue with a figure and no source", async () => { + const { note } = await assertHeld(await scenario({ payload: { markdown: "Prices fell 12% in a year." } }), "checks", /Claim check|claim check|figure/i); + assert.ok(note); + }); + + test("checks: links still being read are asked about again; past the wait the issue is held with why", async () => { + const setup = await scenario({ payload: { markdown: "A plant opened ([report](https://news.example.com/ohio))." } }); + const emails = platformEmails.length; + const first = await delivery.deliverIssue(setup.payload); + assert.equal(first.status, "checking"); + assert.equal(platformEmails.length, emails, "no word to the owner while it waits"); + assert.equal((await campaignOf(first.campaignId)).status, "draft"); + const { result, note } = await (async () => { + const before = platformEmails.length; + const result = await delivery.deliverIssue({ ...setup.payload, checksTimedOut: true }); + return { result, note: notesSince(before)[0] }; + })(); + assert.deepEqual([result.status, result.heldReason], ["drafted", "checks"]); + assert.match(note.text, /did not finish in 10 minutes/); + }); + + test("daily_cap: a briefing past its allowance for the UTC day keeps the next one as a draft", async () => { + const setup = await scenario({ briefing: { autoSendDailyCap: 1 } }); + await BriefingRun.create({ accountId: owner.accountId, briefingId: setup.briefingId, newsletterId: owner.newsletterId, slot: "2026-11-01", status: "sent", autoSentAt: new Date() }); + await assertHeld(setup, "daily_cap", /already went out by itself today \(UTC\), and this briefing's allowance is 1/); + const roomy = await scenario({ briefing: { autoSendDailyCap: 2 } }); + await BriefingRun.create({ accountId: owner.accountId, briefingId: roomy.briefingId, newsletterId: owner.newsletterId, slot: "2026-11-01", status: "sent", autoSentAt: new Date() }); + assert.equal((await deliver(roomy)).result.status, "sent", "a cap of two has room for a second"); + const yesterday = await scenario(); + await BriefingRun.create({ accountId: owner.accountId, briefingId: yesterday.briefingId, newsletterId: owner.newsletterId, slot: "2026-10-31", status: "sent", autoSentAt: new Date(Date.now() - 36 * 3_600_000) }); + assert.equal((await deliver(yesterday)).result.status, "sent", "yesterday's send does not count"); + }); + + test("agent_approval: a newsletter that asks before an agent sends turns the send into an approval request, and approving sends it once", async () => { + await m.Newsletter.updateOne({ accountId: owner.accountId, _id: owner.newsletterId }, { $set: { agentApproval: true } }); + try { + const setup = await scenario(); + const { result, note, campaign } = await assertHeld(setup, "agent_approval", /Waiting for your approval/); + assert.match(note.subject, /waiting for your approval/); + const [approval] = await ApprovalRequest.find({ accountId: owner.accountId, actorId: `briefing:${setup.briefingId}` }).lean(); + assert.deepEqual([approval.status, approval.action, approval.className, approval.reason, approval.via, approval.actorType], ["pending", "send_campaign", "send", "newsletter", "briefing", "app"]); + assert.deepEqual(approval.input, { id: result.campaignId }); + assert.equal(campaign.status, "draft"); + // Asked again (a retried delivery): the same request, not a second. + await delivery.deliverIssue(setup.payload); + assert.equal(await ApprovalRequest.countDocuments({ accountId: owner.accountId, actorId: `briefing:${setup.briefingId}` }), 1); + const decided = await as(owner, () => m.approvals.decideApproval(String(approval._id), "approve")); + assert.equal(decided.approval.executed, true, JSON.stringify(decided)); + assert.ok(SENT.includes((await campaignOf(result.campaignId)).status)); + // A briefing deleted before the owner answers cannot send. + const gone = await scenario(); + await delivery.deliverIssue(gone.payload); + await Briefing.deleteOne({ accountId: owner.accountId, _id: gone.briefingId }); + const [pending] = await ApprovalRequest.find({ accountId: owner.accountId, actorId: `briefing:${gone.briefingId}` }).lean(); + const refused = await as(owner, () => m.approvals.decideApproval(String(pending._id), "approve")); + assert.equal(refused.approval.executed, false); + assert.equal(refused.approval.execution.error.code, "unauthorized"); + } finally { + await m.Newsletter.updateOne({ accountId: owner.accountId, _id: owner.newsletterId }, { $unset: { agentApproval: 1 } }); + } + }); + + test("a send that the normal path refuses is a draft with its words, not an error", async () => { + const setup = await scenario(); + await m.Account.updateOne({ _id: owner.accountId }, { $set: { "usage.emailsSent": 99_999 } }); + try { + const { note } = await assertHeld(setup, "checks", /Kept as a draft: a check did not pass/); + assert.match(note.text, /Plan|plan|emails/); + } finally { + await m.Account.updateOne({ _id: owner.accountId }, { $set: { "usage.emailsSent": 0 } }); + } + }); +}); diff --git a/tests/briefings-docs.test.mjs b/tests/briefings-docs.test.mjs index 4eba26f..2fd235d 100644 --- a/tests/briefings-docs.test.mjs +++ b/tests/briefings-docs.test.mjs @@ -49,7 +49,7 @@ describe("the Briefings guide", () => { for (const phrase of [ "Run a newsletter with an agent", "There is no list of sources to keep up", - "never sends by itself", + "Each issue waits as a draft until you", "topic", "audience", "useful", "examples", "Trusted outlets", "Blocked hosts", "whatever their robots.txt allows", "AI keys", "never shown again", "under the key holder's own TinyFish account and terms", @@ -64,6 +64,7 @@ describe("the Briefings guide", () => { "English, Spanish, French, German, Italian and Portuguese", "beta", "recorded per run on the run page", "an estimate from the prompts, not a measurement", "get_agent_brief", "needsOwner", "apply_editorial_pattern", + "Sending by itself", "set_briefing_auto_send", "daily_cap", "a pause link", "no blocker and no warning", "only you can turn it on", ]) { assert.ok(text.includes(phrase), phrase); } diff --git a/tests/briefings-engine-discovery.test.mjs b/tests/briefings-engine-discovery.test.mjs index e51464f..9c65560 100644 --- a/tests/briefings-engine-discovery.test.mjs +++ b/tests/briefings-engine-discovery.test.mjs @@ -350,6 +350,8 @@ describe("budget shares and holds", () => { assert.equal(hold({ checks: [{ status: "warning" }] }), "checks"); assert.equal(hold({ checks: clean }, {}, { reviewMode: "draft" }), "review_mode"); assert.equal(hold({ checks: clean }), null); + assert.equal(hold({ checks: clean, languages: [{ language: "fr", status: "done" }] }), null, "every translation written"); + for (const status of ["pending", "failed", "skipped_budget", "skipped_no_key"]) assert.equal(hold({ checks: clean, languages: [{ language: "fr", status }] }), "partial", status); }); test("the translate prompt keeps the citations and Markdown, and the page text stays data", () => { diff --git a/tests/briefings-engine.test.mjs b/tests/briefings-engine.test.mjs index 4f0e368..9295e86 100644 --- a/tests/briefings-engine.test.mjs +++ b/tests/briefings-engine.test.mjs @@ -291,6 +291,7 @@ before(async () => { m = { ObjectId: docdb.ObjectId, engine: await import("../lib/briefings/index.js"), + stages: await import("../lib/briefings/stages.js"), rank: await import("../lib/briefings/rank.js"), robots: await import("../lib/briefings/robots.js"), schedule: await import("../lib/briefing-schedule.js"), @@ -317,6 +318,89 @@ beforeEach(async () => { await m.BriefingRun.updateMany({ status: "running" }, { $set: { status: "failed" } }).setOptions({ crossAccount: true }); }); +describe("sending by itself, as the engine hands a run to delivery", () => { + // Delivery decides the send (lib/briefings-delivery.js); the engine hands over the run's own verdict and takes its answer. + function deliveryAnswering(answer) { + const delivery = makeDelivery(); + delivery.deliverIssue = async (payload) => { + delivery.issues.push(payload); + return answer(payload, delivery.issues.length); + }; + return delivery; + } + + test("a clean run in send mode reaches delivery with no held reason, and a delivery that sent finishes it as sent", async () => { + const world = makeWorld(); + const delivery = deliveryAnswering(() => ({ campaignId: CAMPAIGN_ID, status: "sent" })); + const { briefing } = await makeBriefing({ reviewMode: "send_if_checks_pass", languages: ["en", "fr"] }); + await tick({ world, delivery, ai }); + const run = await runOf(briefing); + assert.equal(run.status, "sent", run.error); + assert.equal(run.heldReason, undefined); + assert.equal(String(run.campaignId), CAMPAIGN_ID); + assert.deepEqual([delivery.issues[0].heldReason, delivery.issues[0].reviewMode], [null, "send_if_checks_pass"]); + assert.equal(await m.BriefingSeen.countDocuments({ accountId: briefing.accountId, briefingId: briefing._id }), 8, "a sent run marks what it used as seen, as a drafted one does"); + }); + + test("while the checks are being read the stage asks again, with the same payload, and then finishes", async () => { + const world = makeWorld(); + const delivery = deliveryAnswering((payload, count) => (count < 3 ? { campaignId: CAMPAIGN_ID, status: "checking" } : { campaignId: CAMPAIGN_ID, status: "sent" })); + const { briefing } = await makeBriefing({ reviewMode: "send_if_checks_pass" }); + await tick({ world, delivery, ai }); + const run = await runOf(briefing); + assert.equal(run.status, "sent", run.error); + assert.equal(delivery.issues.length, 3); + assert.ok(delivery.issues.every((issue) => issue.checksTimedOut === undefined && issue.markdown === delivery.issues[0].markdown)); + assert.ok(world.sleeps.includes(4000), "it waited between questions"); + }); + + test("checks that never finish are given up on after ten minutes: delivery is told to decide with what it has", async () => { + const answers = []; + const ctx = { + run: { _id: "r", accountId: "a", newsletterId: "n", slot: "2026-11-02", checks: [], discoveryNotes: [], sections: [], languages: [] }, + briefing: { _id: "b", reviewMode: "send_if_checks_pass" }, + work: { records: [], draft: { subject: "S", previewText: "P", markdown: "M" }, linkedMarkdown: "M", translations: [] }, + meter: { spent: () => 0 }, + clock: () => new Date(), + canWait: () => true, + sleep: async () => {}, + delivery: { + async deliverIssue(payload) { + answers.push(payload.checksTimedOut === true); + return payload.checksTimedOut ? { campaignId: CAMPAIGN_ID, status: "drafted", heldReason: "checks" } : { campaignId: CAMPAIGN_ID, status: "checking" }; + }, + }, + }; + assert.deepEqual(await m.stages.STAGES.deliver(ctx), { partial: true }, "asked again"); + assert.ok(ctx.work.checkingSince); + assert.deepEqual(await m.stages.STAGES.deliver({ ...ctx, canWait: () => false }), { yield: true }, "no time left in this tick: the next one asks"); + ctx.work.checkingSince -= 11 * 60_000; + assert.deepEqual(await m.stages.STAGES.deliver(ctx), { finish: "drafted", campaignId: CAMPAIGN_ID }); + assert.equal(ctx.run.heldReason, "checks"); + assert.deepEqual(answers, [false, false, false, true]); + }); + + test("delivery's own reason for holding the issue replaces the engine's, and the run is a draft", async () => { + const world = makeWorld(); + const delivery = deliveryAnswering(() => ({ campaignId: CAMPAIGN_ID, status: "drafted", heldReason: "daily_cap" })); + const { briefing } = await makeBriefing({ reviewMode: "send_if_checks_pass" }); + await tick({ world, delivery, ai }); + const run = await runOf(briefing); + assert.deepEqual([run.status, run.heldReason], ["drafted", "daily_cap"]); + }); + + test("a translation that was not written holds a send-mode run as partial", async () => { + const world = makeWorld(); + const delivery = makeDelivery(); + const { briefing } = await makeBriefing({ reviewMode: "send_if_checks_pass", languages: ["en", "fr"], steps: () => ({ translate: null }) }); + await tick({ world, delivery, ai }); + const run = await runOf(briefing); + assert.deepEqual(run.languages, [{ language: "fr", status: "skipped_no_key" }]); + assert.equal(run.heldReason, "partial"); + assert.equal(delivery.issues[0].heldReason, "partial"); + }); +}); + describe("a full run with open-web discovery", () => { test("goes from queries and free indexes to a sourced, checked, translated draft", async () => { const world = makeWorld();