diff --git a/.changeset/olive-donuts-guess.md b/.changeset/olive-donuts-guess.md new file mode 100644 index 00000000..86fc51fa --- /dev/null +++ b/.changeset/olive-donuts-guess.md @@ -0,0 +1,15 @@ +--- +'@getmunin/backend-core': minor +'@getmunin/inspector-app': minor +'@getmunin/dashboard-pages': patch +--- + +Outbound calls and text messages are approved only by a person in the dashboard + +A proposal whose campaign runs on a voice or SMS channel can now only be approved by a signed-in dashboard user. `outreach_approve_proposal` refuses every other caller — an MCP agent, an unrestricted admin API key on the control plane, a curator, the Slack approval button — and the proposal stays `pending`. Email approval is unchanged, including by agents and admin keys. + +The check lives in `OutreachService.approveProposal`, so it holds regardless of which surface the call arrives through. It is not an MCP tool-visibility hint: hosts that don't implement MCP Apps still list the tool, and calling it on a voice proposal fails there too. + +Slack declines these in-thread with a pointer to the dashboard rather than surfacing a service error. The Inspector panel drops the approve button for voice and SMS proposals — Dismiss stays, since dismissing sends nothing — and says where the call is actually placed. + +Quiet hours and blackout dates are now enforced when a call or text is approved. They were previously stored on the campaign and consulted only when listing due follow-ups, so nothing stopped a call at 3am. `cadenceRules` gains an optional `quietHoursTimezone` (IANA, validated) that quiet hours and blackout dates are read in; without it they are read in UTC, which is not what a Norwegian campaign means by "no calls before 08:00". diff --git a/packages/backend-core/docs-fixtures/mcp-tools.json b/packages/backend-core/docs-fixtures/mcp-tools.json index 25acd56e..31f9c34c 100644 --- a/packages/backend-core/docs-fixtures/mcp-tools.json +++ b/packages/backend-core/docs-fixtures/mcp-tools.json @@ -6837,6 +6837,12 @@ "type": "string", "pattern": "^[0-2]\\d:[0-5]\\d$" }, + "quietHoursTimezone": { + "description": "IANA time zone the quiet hours and blackout dates are read in, e.g. \"Europe/Oslo\". Defaults to UTC.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, "blackoutDates": { "maxItems": 50, "type": "array", @@ -6986,6 +6992,12 @@ "type": "string", "pattern": "^[0-2]\\d:[0-5]\\d$" }, + "quietHoursTimezone": { + "description": "IANA time zone the quiet hours and blackout dates are read in, e.g. \"Europe/Oslo\". Defaults to UTC.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, "blackoutDates": { "maxItems": 50, "type": "array", @@ -7244,6 +7256,12 @@ "type": "string", "pattern": "^[0-2]\\d:[0-5]\\d$" }, + "quietHoursTimezone": { + "description": "IANA time zone the quiet hours and blackout dates are read in, e.g. \"Europe/Oslo\". Defaults to UTC.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, "blackoutDates": { "maxItems": 50, "type": "array", diff --git a/packages/backend-core/docs-fixtures/skills.json b/packages/backend-core/docs-fixtures/skills.json index 900e6efe..2520da43 100644 --- a/packages/backend-core/docs-fixtures/skills.json +++ b/packages/backend-core/docs-fixtures/skills.json @@ -319,9 +319,9 @@ "module": "outreach", "slug": "review-proposals", "title": "Outreach: Review pending proposals", - "description": "Operator review pass over drafted outreach proposals — approve (which sends) or dismiss each pending draft, and the two agent-side corrections, revise and withdraw. In MCP App hosts this renders the interactive Munin Inspector panel; elsewhere, drive the same decision tools directly.", + "description": "Operator review pass over drafted outreach proposals — approve (which sends) or dismiss each pending draft, and the two agent-side corrections, revise and withdraw. Voice and SMS proposals are approved only in the Munin dashboard. In MCP App hosts this renders the interactive Munin Inspector panel.", "mimeType": "text/markdown", - "content": "# Review pending outreach proposals\n\nEvery outbound email in Munin ships through a human-approved gate: curators file drafts as **pending proposals** (`skill://outreach/draft-initial-email`, `skill://outreach/draft-reply-email`, `skill://outreach/draft-followup-email`), and nothing leaves the org until an operator — or an admin agent acting on their explicit instruction — decides each one. This skill is that decision pass.\n\n**Approving sends.** `outreach_approve_proposal` is not a status flip: for an `initial` proposal it creates the outbound conversation and sends the first email through the campaign's channel (appending the CTA link and unsubscribe footer per campaign settings); for a `reply` or `followup` it sends the draft verbatim on the existing conversation. There is no undo. Never approve in bulk without reading each draft.\n\n**Dismissing a follow-up stops the sequence.** A dismissed `followup` permanently ends the campaign's follow-up sequence for that contact — no later step will be drafted. That makes dismiss the right call for \"stop chasing this person\" and the wrong call for \"reword this\". For wording, revise the draft in place (below) or edit it in the dashboard review drawer, then approve the edited version.\n\n## Four verbs, four different meanings\n\n| Tool | Who it's for | What it means |\n|---|---|---|\n| `outreach_approve_proposal` | operator | Send it. No undo. |\n| `outreach_dismiss_proposal` | operator | *Rejected.* A judgement about this draft; on a `followup` it also stops the sequence. |\n| `outreach_revise_proposal` | agent | Same proposal, better text. Recipient and campaign are fixed. |\n| `outreach_withdraw_proposal` | agent | *Never mind* — the draft should not have been filed. Neutral. |\n\nDismiss is a decision about the draft; withdraw is the agent admitting the draft was a mistake. Don't reach for dismiss to clean up after yourself, and don't withdraw a draft an operator asked you to reject — the reasons land in different fields and read differently in the audit trail.\n\n## Revising a pending draft\n\n`outreach_revise_proposal({ \"id\": \"...\", \"reason\": \"...\", \"draftBody\": \"...\" })` rewrites the draft in place. The proposal id, the contact, and the campaign do not change — a different recipient is a different proposal, so file a new one instead. `draftSubject` and `proposedSendAt` can be revised the same way, and `reason` is required.\n\nThe revision is recorded, not silent. Each call bumps `revisionCount` and stamps `lastRevisedAt`, `lastRevisionReason`, and the revising actor. If somebody else had already opened the draft for review before your change, `revisedAfterReviewAt` is stamped too and the review surfaces flag it — the operator who read Monday's text gets told, in the panel and in the dashboard drawer, that Wednesday's text is not what they read.\n\nThat flag is the point. **Never revise a draft an operator is mid-review on and then ask them to approve as if nothing changed.** If you revise after review, say so in the same breath you present it, and let them re-read the full body.\n\n## Withdrawing your own draft\n\n`outreach_withdraw_proposal({ \"id\": \"...\", \"reason\": \"...\" })` retracts a pending proposal you should not have filed: you drafted the same person twice, the prospect turned out not to qualify, the address bounced, the campaign premise no longer holds. Nothing is sent and `reason` is required.\n\nWithdrawal is deliberately neutral. It does **not** suppress the contact, does **not** touch their consent, and does **not** stop a campaign sequence — a withdrawn `followup` leaves that step eligible again, unlike a dismissed one. If the contact genuinely should never be contacted again, that is a suppression decision: `crm_set_contact_consent` / the do-not-contact flag, not a withdrawal.\n\nBecause withdrawal clears the pending slot for that (campaign, contact, kind), you can withdraw a bad draft and file a corrected one. Prefer `outreach_revise_proposal` when the recipient is right and only the text is wrong — withdraw-and-refile loses the review history.\n\n## In an MCP App host (Claude, Claude Desktop, …)\n\nCall `outreach_list_proposals({ \"status\": \"pending\" })`. Hosts that support MCP Apps render the **Munin Inspector** panel (`ui://munin/inspector`) inline: one card per proposal with the contact, campaign, draft subject/body, and the curator's evidence, plus **Approve & send** and **Dismiss** buttons that call the decision tools directly. In these hosts the decision tools are **panel-only** (`_meta.ui.visibility: [\"app\"]`): they are hidden from you and only the operator's click can invoke them. Render the list and stop — the send decision is physically the human's.\n\n## Without a panel\n\nIn hosts without MCP Apps support the decision tools appear normally, and the same flow works as plain tool calls:\n\n1. **List** — `outreach_list_proposals({ \"status\": \"pending\" })`. Each row carries `id`, `kind`, `draftSubject`, `draftBody`, `evidence`, `proposedSendAt`, and nested `contact` / `campaign` summaries.\n2. **Present each draft** to the operator: who it goes to, which campaign, the subject, the full body, and anything notable in `evidence`. Don't paraphrase the body — the operator is approving the literal text.\n3. **Decide one at a time** on the operator's word:\n - `outreach_approve_proposal({ \"id\": \"...\" })` — sends immediately; the result carries `status: \"sent\"`, `conversationId`, `sentMessageId`.\n - `outreach_dismiss_proposal({ \"id\": \"...\", \"reason\": \"...\" })` — no send; the reason lands on the proposal for the curator's next pass.\n4. **Handle refusals cleanly.** Both tools reject non-`pending` proposals (someone else may have decided it since listing — refresh rather than retry). Approval also rejects when the campaign was disabled or the contact became suppressed since drafting, and — for `followup` proposals — when the prospect replied after the draft was filed (dismiss it; the reply flow owns the conversation now). That is the suppression floor and stop-on-reply working, not an error to route around.\n\n## What not to do\n\n- **Never approve on your own initiative.** A pending queue is not permission. The invariant that makes propose-only outreach safe is that a human read each draft.\n- **Don't revise-and-approve in one breath.** A revision resets what the operator needs to read. Revise, re-present the full body, then wait for their word.\n- **Don't withdraw a draft to escape a refusal.** If approval failed because the contact was suppressed or the prospect replied, that is the safety floor doing its job — dismiss it (or leave it) rather than withdrawing to clear the slot and re-drafting around the block.\n- **Don't loop approve over the whole list** (\"approve all\") unless the operator explicitly reviewed every draft and said exactly that.\n" + "content": "# Review pending outreach proposals\n\nEvery outbound email in Munin ships through a human-approved gate: curators file drafts as **pending proposals** (`skill://outreach/draft-initial-email`, `skill://outreach/draft-reply-email`, `skill://outreach/draft-followup-email`), and nothing leaves the org until an operator — or an admin agent acting on their explicit instruction — decides each one. This skill is that decision pass.\n\n**Calls and text messages are approved in the dashboard, never here.** A proposal whose campaign runs on a voice or SMS channel can only be approved by a signed-in person in the Munin dashboard. `outreach_approve_proposal` refuses every other caller — an agent, an admin API key, the Slack button — with `outreach_invalid: … approved by a signed-in person in the Munin dashboard`. That is the safety floor for outbound calling, not a configuration you can route around: don't retry, don't look for another tool, and don't ask for a credential that would work. Present the draft, say it is waiting for someone to place the call from the dashboard inbox, and stop. You can still `outreach_revise_proposal`, `outreach_withdraw_proposal`, and `outreach_dismiss_proposal` on these — none of them send anything.\n\n**Approving sends.** `outreach_approve_proposal` is not a status flip: for an `initial` proposal it creates the outbound conversation and sends the first email through the campaign's channel (appending the CTA link and unsubscribe footer per campaign settings); for a `reply` or `followup` it sends the draft verbatim on the existing conversation. There is no undo. Never approve in bulk without reading each draft.\n\n**Dismissing a follow-up stops the sequence.** A dismissed `followup` permanently ends the campaign's follow-up sequence for that contact — no later step will be drafted. That makes dismiss the right call for \"stop chasing this person\" and the wrong call for \"reword this\". For wording, revise the draft in place (below) or edit it in the dashboard review drawer, then approve the edited version.\n\n## Four verbs, four different meanings\n\n| Tool | Who it's for | What it means |\n|---|---|---|\n| `outreach_approve_proposal` | operator | Send it. No undo. Email only — voice and SMS are dashboard-only. |\n| `outreach_dismiss_proposal` | operator | *Rejected.* A judgement about this draft; on a `followup` it also stops the sequence. |\n| `outreach_revise_proposal` | agent | Same proposal, better text. Recipient and campaign are fixed. |\n| `outreach_withdraw_proposal` | agent | *Never mind* — the draft should not have been filed. Neutral. |\n\nDismiss is a decision about the draft; withdraw is the agent admitting the draft was a mistake. Don't reach for dismiss to clean up after yourself, and don't withdraw a draft an operator asked you to reject — the reasons land in different fields and read differently in the audit trail.\n\n## Revising a pending draft\n\n`outreach_revise_proposal({ \"id\": \"...\", \"reason\": \"...\", \"draftBody\": \"...\" })` rewrites the draft in place. The proposal id, the contact, and the campaign do not change — a different recipient is a different proposal, so file a new one instead. `draftSubject` and `proposedSendAt` can be revised the same way, and `reason` is required.\n\nThe revision is recorded, not silent. Each call bumps `revisionCount` and stamps `lastRevisedAt`, `lastRevisionReason`, and the revising actor. If somebody else had already opened the draft for review before your change, `revisedAfterReviewAt` is stamped too and the review surfaces flag it — the operator who read Monday's text gets told, in the panel and in the dashboard drawer, that Wednesday's text is not what they read.\n\nThat flag is the point. **Never revise a draft an operator is mid-review on and then ask them to approve as if nothing changed.** If you revise after review, say so in the same breath you present it, and let them re-read the full body.\n\n## Withdrawing your own draft\n\n`outreach_withdraw_proposal({ \"id\": \"...\", \"reason\": \"...\" })` retracts a pending proposal you should not have filed: you drafted the same person twice, the prospect turned out not to qualify, the address bounced, the campaign premise no longer holds. Nothing is sent and `reason` is required.\n\nWithdrawal is deliberately neutral. It does **not** suppress the contact, does **not** touch their consent, and does **not** stop a campaign sequence — a withdrawn `followup` leaves that step eligible again, unlike a dismissed one. If the contact genuinely should never be contacted again, that is a suppression decision: `crm_set_contact_consent` / the do-not-contact flag, not a withdrawal.\n\nBecause withdrawal clears the pending slot for that (campaign, contact, kind), you can withdraw a bad draft and file a corrected one. Prefer `outreach_revise_proposal` when the recipient is right and only the text is wrong — withdraw-and-refile loses the review history.\n\n## In an MCP App host (Claude, Claude Desktop, …)\n\nCall `outreach_list_proposals({ \"status\": \"pending\" })`. Hosts that support MCP Apps render the **Munin Inspector** panel (`ui://munin/inspector`) inline: one card per proposal with the contact, campaign, draft subject/body, the curator's evidence, and a `delivery` line naming the address or phone number the approval would reach. In these hosts the decision tools are **panel-only** (`_meta.ui.visibility: [\"app\"]`): they are hidden from you and only the operator's click can invoke them. Render the list and stop — the send decision is physically the human's.\n\nEmail proposals get **Approve & send** and **Dismiss** buttons. Voice and SMS proposals get **Dismiss** only, plus a line pointing the operator at the dashboard — the panel cannot place a call, because the server refuses any approval that does not come from a signed-in dashboard session.\n\n## Without a panel\n\nIn hosts without MCP Apps support the decision tools appear as ordinary tools, and the same flow works as plain tool calls — for **email campaigns only**:\n\n1. **List** — `outreach_list_proposals({ \"status\": \"pending\" })`. Each row carries `id`, `kind`, `draftSubject`, `draftBody`, `evidence`, `proposedSendAt`, nested `contact` / `campaign` summaries, and `delivery`.\n2. **Read `delivery` first.** It names the channel and the exact destination. If `delivery.channelType` is `voice` or `sms`, there is nothing for you to approve: present the draft and the number, tell the operator it is waiting in the dashboard inbox, and move on. If `delivery.destination` is `null`, the contact has no address or number on file and approval would fail — say so instead of trying.\n3. **Present each draft** to the operator: who it goes to, which campaign, the subject, the full body, and anything notable in `evidence`. Don't paraphrase the body — the operator is approving the literal text. `delivery.appendsCta` / `appendsUnsubscribe` tell you what the system will add on top, so the operator isn't surprised by a footer they didn't read.\n4. **Decide one at a time** on the operator's word:\n - `outreach_approve_proposal({ \"id\": \"...\" })` — sends immediately; the result carries `status: \"sent\"`, `conversationId`, `sentMessageId`.\n - `outreach_dismiss_proposal({ \"id\": \"...\", \"reason\": \"...\" })` — no send; the reason lands on the proposal for the curator's next pass.\n5. **Handle refusals cleanly.** Both tools reject non-`pending` proposals (someone else may have decided it since listing — refresh rather than retry). Approval also rejects when the campaign was disabled, when the contact became suppressed since drafting, when the campaign is inside its quiet hours or on a blackout date, and — for `followup` proposals — when the prospect replied after the draft was filed (dismiss it; the reply flow owns the conversation now). That is the safety floor working, not an error to route around.\n\n## What not to do\n\n- **Never approve on your own initiative.** A pending queue is not permission. The invariant that makes propose-only outreach safe is that a human read each draft.\n- **Don't revise-and-approve in one breath.** A revision resets what the operator needs to read. Revise, re-present the full body, then wait for their word.\n- **Don't withdraw a draft to escape a refusal.** If approval failed because the contact was suppressed or the prospect replied, that is the safety floor doing its job — dismiss it (or leave it) rather than withdrawing to clear the slot and re-drafting around the block.\n- **Don't loop approve over the whole list** (\"approve all\") unless the operator explicitly reviewed every draft and said exactly that.\n- **Don't try to place a call.** If a voice or SMS approval is refused, that refusal is the product working as designed. There is no tool, key, or argument that makes it succeed — outbound calls and texts leave Munin only when a person clicks in the dashboard.\n" }, { "uri": "skill://playbooks/customer-acquisition", diff --git a/packages/backend-core/src/control/outreach-transfer.controller.ts b/packages/backend-core/src/control/outreach-transfer.controller.ts index 47fc60f6..01b3f02c 100644 --- a/packages/backend-core/src/control/outreach-transfer.controller.ts +++ b/packages/backend-core/src/control/outreach-transfer.controller.ts @@ -35,6 +35,7 @@ const ImportBody = z.object({ maxPerWeekPerContact: z.number().int().positive().max(7).optional(), quietHoursStart: z.string().optional(), quietHoursEnd: z.string().optional(), + quietHoursTimezone: z.string().optional(), blackoutDates: z.array(z.string()).optional(), }) .default({}), diff --git a/packages/backend-core/src/modules/outreach/outreach.integration.test.ts b/packages/backend-core/src/modules/outreach/outreach.integration.test.ts index f82dbce9..f52328be 100644 --- a/packages/backend-core/src/modules/outreach/outreach.integration.test.ts +++ b/packages/backend-core/src/modules/outreach/outreach.integration.test.ts @@ -341,6 +341,20 @@ const skipReason = TEST_URL expect(listedProposals.find((p) => p.id === proposal.id)?.delivery?.destination).toBe( '+14155559999', ); + + const approved = await c.callTool({ + name: 'outreach_approve_proposal', + arguments: { id: proposal.id }, + }); + expect(approved.isError).toBe(true); + expect(String(firstJson(approved))).toMatch(/signed-in person in the Munin dashboard/); + + const after = await c.callTool({ + name: 'outreach_list_proposals', + arguments: { campaignId: campaign.id }, + }); + const stillPending = firstJson(after) as Array<{ id: string; status: string }>; + expect(stillPending.find((p) => p.id === proposal.id)?.status).toBe('pending'); }); }); diff --git a/packages/backend-core/src/modules/outreach/outreach.service.test.ts b/packages/backend-core/src/modules/outreach/outreach.service.test.ts index c9864454..cc9f3681 100644 --- a/packages/backend-core/src/modules/outreach/outreach.service.test.ts +++ b/packages/backend-core/src/modules/outreach/outreach.service.test.ts @@ -124,12 +124,16 @@ const skipReason = TEST_URL }); function run(fn: () => Promise): Promise { + return runAs(actor, fn); + } + + function runAs(as: ActorIdentity, fn: () => Promise): Promise { return appDb.transaction(async (tx) => { await tx.execute(sql`SELECT set_config('app.bypass_rls', 'off', true)`); await tx.execute(sql`SELECT set_config('app.org_id', ${orgId}, true)`); const ctx: RequestContext = { db: tx, - actor, + actor: as, correlationId: randomUUID(), }; return withContext(ctx, fn); @@ -1138,13 +1142,27 @@ const skipReason = TEST_URL globalThis.fetch = realFetch; }); - function runAsSystem(fn: () => Promise): Promise { + function humanActor(): ActorIdentity { + return new ActorIdentity( + 'user', + 'usr_outreach_voice_test', + orgId, + ['*'], + ['admin'], + undefined, + undefined, + undefined, + 'usr_outreach_voice_test', + ); + } + + function runAsSystem(fn: () => Promise, as: ActorIdentity = actor): Promise { return db.transaction(async (tx) => { await tx.execute(sql`SELECT set_config('app.bypass_rls', 'on', true)`); await tx.execute( sql`SELECT set_config('app.crypt_key', ${process.env.MUNIN_ENCRYPTION_KEY ?? ''}, true)`, ); - const ctx: RequestContext = { db: tx, actor, correlationId: randomUUID() }; + const ctx: RequestContext = { db: tx, actor: as, correlationId: randomUUID() }; return withContext(ctx, fn); }); } @@ -1311,8 +1329,9 @@ const skipReason = TEST_URL draftBody: 'Quick follow-up call.', }), ); - const approved = await runAsSystem(() => - svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }), + const approved = await runAsSystem( + () => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }), + humanActor(), ); expect(approved.status).toBe('sent'); expect(approved.conversationId).toBeTruthy(); @@ -1370,8 +1389,9 @@ const skipReason = TEST_URL draftBody: 'Hi.', }), ); - const approved = await runAsSystem(() => - svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }), + const approved = await runAsSystem( + () => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }), + humanActor(), ); expect(approved.conversationId).toBe(preexistingId); const all = await db @@ -1408,8 +1428,9 @@ const skipReason = TEST_URL draftBody: 'Quick call.', }), ); - const approved = await runAsSystem(() => - svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }), + const approved = await runAsSystem( + () => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }), + humanActor(), ); await expect( run(() => @@ -1420,5 +1441,139 @@ const skipReason = TEST_URL ), ).rejects.toBeInstanceOf(OutreachInvalidError); }); + + it('an agent cannot approve a voice proposal, and no call is placed', async () => { + const { calls } = stubVapiPlaceCall(); + const c = await run(() => + svc.createCampaign({ + name: 'voice-agent-refused', + brief: 'b', + segmentId, + channelId: voiceChannelId, + enabled: true, + }), + ); + const p = await run(() => + svc.proposeInitial({ + campaignId: c.id, + contactId: voiceContactId, + draftBody: 'Quick call.', + }), + ); + await expect( + runAsSystem(() => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' })), + ).rejects.toThrow(/signed-in person in the Munin dashboard/); + expect(calls.length).toBe(0); + const after = await run(() => svc.getProposal(p.id)); + expect(after.status).toBe('pending'); + }); + + it('a human cannot approve a voice proposal inside the campaign quiet hours', async () => { + const { calls } = stubVapiPlaceCall(); + const now = new Date(); + const quietStart = new Date(now.getTime() - 60 * 60 * 1000); + const quietEnd = new Date(now.getTime() + 60 * 60 * 1000); + const hhmm = (d: Date) => + `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`; + const c = await run(() => + svc.createCampaign({ + name: 'voice-quiet-hours', + brief: 'b', + segmentId, + channelId: voiceChannelId, + enabled: true, + cadenceRules: { quietHoursStart: hhmm(quietStart), quietHoursEnd: hhmm(quietEnd) }, + }), + ); + const p = await run(() => + svc.proposeInitial({ + campaignId: c.id, + contactId: voiceContactId, + draftBody: 'Quick call.', + }), + ); + await expect( + runAsSystem( + () => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }), + humanActor(), + ), + ).rejects.toThrow(/does not call between/); + expect(calls.length).toBe(0); + }); + + it('a human cannot approve a voice proposal on a blackout date', async () => { + const { calls } = stubVapiPlaceCall(); + const c = await run(() => + svc.createCampaign({ + name: 'voice-blackout', + brief: 'b', + segmentId, + channelId: voiceChannelId, + enabled: true, + cadenceRules: { blackoutDates: [new Date().toISOString().slice(0, 10)] }, + }), + ); + const p = await run(() => + svc.proposeInitial({ + campaignId: c.id, + contactId: voiceContactId, + draftBody: 'Quick call.', + }), + ); + await expect( + runAsSystem( + () => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }), + humanActor(), + ), + ).rejects.toThrow(/does not call on /); + expect(calls.length).toBe(0); + }); + + it('an agent can still dismiss a voice proposal, since dismissing sends nothing', async () => { + const c = await run(() => + svc.createCampaign({ + name: 'voice-dismissable', + brief: 'b', + segmentId, + channelId: voiceChannelId, + enabled: true, + }), + ); + const p = await run(() => + svc.proposeInitial({ + campaignId: c.id, + contactId: voiceContactId, + draftBody: 'Quick call.', + }), + ); + const dismissed = await run(() => + svc.dismissProposal({ id: p.id, reason: 'wrong list' }), + ); + expect(dismissed.status).toBe('dismissed'); + }); + + it('email approval is unaffected by the calling gate', async () => { + const c = await run(() => + svc.createCampaign({ + name: 'email-still-agent-approvable', + brief: 'b', + segmentId, + channelId, + enabled: true, + }), + ); + const p = await run(() => + svc.proposeInitial({ + campaignId: c.id, + contactId, + draftSubject: 'Hello', + draftBody: 'Body.', + }), + ); + const approved = await run(() => + svc.approveProposal(p.id, { publicBaseUrl: 'https://test.local' }), + ); + expect(approved.status).toBe('sent'); + }); }); }); diff --git a/packages/backend-core/src/modules/outreach/outreach.service.ts b/packages/backend-core/src/modules/outreach/outreach.service.ts index c6fca228..f61f17aa 100644 --- a/packages/backend-core/src/modules/outreach/outreach.service.ts +++ b/packages/backend-core/src/modules/outreach/outreach.service.ts @@ -29,6 +29,8 @@ export class OutreachInvalidError extends Error { export const PROPOSAL_KINDS = ['initial', 'reply', 'followup'] as const; export type ProposalKind = (typeof PROPOSAL_KINDS)[number]; +export const CHANNELS_REQUIRING_HUMAN_APPROVAL: readonly string[] = ['voice', 'sms']; + export const PROPOSAL_STATUSES = [ 'pending', 'approved', @@ -43,6 +45,7 @@ export interface CadenceRules { maxPerWeekPerContact?: number; quietHoursStart?: string; quietHoursEnd?: string; + quietHoursTimezone?: string; blackoutDates?: string[]; } @@ -750,6 +753,7 @@ export class OutreachService { if (proposal.status !== 'pending') { throw new OutreachInvalidError(`proposal ${id} is ${proposal.status}, not pending`); } + await this.assertApprovableNow(proposal, actor); if (proposal.kind === 'initial') { return this.approveInitial(proposal, actor, opts); } @@ -1455,6 +1459,27 @@ export class OutreachService { }; } + private async assertApprovableNow( + proposal: ProposalDto, + actor: NonNullable['actor']>, + ): Promise { + const channelType = proposal.delivery?.channelType ?? 'email'; + if (!CHANNELS_REQUIRING_HUMAN_APPROVAL.includes(channelType)) return; + if (actor.type !== 'user') { + throw new OutreachInvalidError( + `${channelType} proposals are approved by a signed-in person in the Munin dashboard, ` + + `not by an agent or API key — this caller is ${actor.type}`, + ); + } + const campaign = await this.getCampaign(proposal.campaignId); + const blocked = outsideCallingWindow(campaign.cadenceRules, new Date()); + if (blocked) { + throw new OutreachInvalidError( + `campaign ${campaign.id} does not ${channelType === 'voice' ? 'call' : 'message'} ${blocked}`, + ); + } + } + private async assertSegmentExists(segmentId: string): Promise { const ctx = getCurrentContext(); const rows = await ctx.db @@ -1728,6 +1753,57 @@ function toProposalDto( }; } +function outsideCallingWindow(rules: CadenceRules, now: Date): string | null { + const zone = rules.quietHoursTimezone; + const local = localDateAndMinute(now, zone); + if (rules.blackoutDates?.includes(local.date)) return `on ${local.date}`; + const { quietHoursStart, quietHoursEnd } = rules; + if (!quietHoursStart || !quietHoursEnd) return null; + const start = toMinuteOfDay(quietHoursStart); + const end = toMinuteOfDay(quietHoursEnd); + if (start === end) return null; + const quiet = + start < end + ? local.minute >= start && local.minute < end + : local.minute >= start || local.minute < end; + if (!quiet) return null; + const where = zone ? `${quietHoursStart}–${quietHoursEnd} ${zone}` : `${quietHoursStart}–${quietHoursEnd} UTC`; + return `between ${where}`; +} + +function localDateAndMinute(now: Date, timeZone: string | undefined): { date: string; minute: number } { + const utc = { + date: now.toISOString().slice(0, 10), + minute: now.getUTCHours() * 60 + now.getUTCMinutes(), + }; + if (!timeZone) return utc; + let parts: Intl.DateTimeFormatPart[]; + try { + parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(now); + } catch { + return utc; + } + const at = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'; + const hour = at('hour') === '24' ? '00' : at('hour'); + return { + date: `${at('year')}-${at('month')}-${at('day')}`, + minute: Number(hour) * 60 + Number(at('minute')), + }; +} + +function toMinuteOfDay(hhmm: string): number { + const [h, m] = hhmm.split(':'); + return Number(h) * 60 + Number(m); +} + function toProposalDelivery( contact: { email: string | null; phone?: string | null } | null, campaign: { ctaUrl?: string | null; unsubscribeRequired?: boolean | null } | null, diff --git a/packages/backend-core/src/modules/outreach/outreach.tools.ts b/packages/backend-core/src/modules/outreach/outreach.tools.ts index 6cf64690..6fd0af28 100644 --- a/packages/backend-core/src/modules/outreach/outreach.tools.ts +++ b/packages/backend-core/src/modules/outreach/outreach.tools.ts @@ -11,10 +11,28 @@ import { import { IdMapSchema } from '../../common/transfer/transfer.types.ts'; import { INSPECTOR_APP_URI } from '../../mcp/inspector.resource.ts'; +function isKnownTimeZone(value: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone: value }); + return true; + } catch { + return false; + } +} + +const IanaTimeZone = z + .string() + .min(1) + .max(64) + .refine(isKnownTimeZone, { message: 'must be an IANA time zone, e.g. "Europe/Oslo"' }); + const CadenceRulesSchema = z.object({ maxPerWeekPerContact: z.number().int().positive().max(7).optional(), quietHoursStart: z.string().regex(/^[0-2]\d:[0-5]\d$/).optional(), quietHoursEnd: z.string().regex(/^[0-2]\d:[0-5]\d$/).optional(), + quietHoursTimezone: IanaTimeZone.optional().describe( + 'IANA time zone the quiet hours and blackout dates are read in, e.g. "Europe/Oslo". Defaults to UTC.', + ), blackoutDates: z.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)).max(50).optional(), }); diff --git a/packages/backend-core/src/modules/outreach/skills/review-proposals.md b/packages/backend-core/src/modules/outreach/skills/review-proposals.md index 5b9465b1..b0dd24a6 100644 --- a/packages/backend-core/src/modules/outreach/skills/review-proposals.md +++ b/packages/backend-core/src/modules/outreach/skills/review-proposals.md @@ -1,6 +1,6 @@ --- title: Outreach: Review pending proposals -description: Operator review pass over drafted outreach proposals — approve (which sends) or dismiss each pending draft, and the two agent-side corrections, revise and withdraw. In MCP App hosts this renders the interactive Munin Inspector panel; elsewhere, drive the same decision tools directly. +description: Operator review pass over drafted outreach proposals — approve (which sends) or dismiss each pending draft, and the two agent-side corrections, revise and withdraw. Voice and SMS proposals are approved only in the Munin dashboard. In MCP App hosts this renders the interactive Munin Inspector panel. audiences: [admin] --- @@ -8,6 +8,8 @@ audiences: [admin] Every outbound email in Munin ships through a human-approved gate: curators file drafts as **pending proposals** (`skill://outreach/draft-initial-email`, `skill://outreach/draft-reply-email`, `skill://outreach/draft-followup-email`), and nothing leaves the org until an operator — or an admin agent acting on their explicit instruction — decides each one. This skill is that decision pass. +**Calls and text messages are approved in the dashboard, never here.** A proposal whose campaign runs on a voice or SMS channel can only be approved by a signed-in person in the Munin dashboard. `outreach_approve_proposal` refuses every other caller — an agent, an admin API key, the Slack button — with `outreach_invalid: … approved by a signed-in person in the Munin dashboard`. That is the safety floor for outbound calling, not a configuration you can route around: don't retry, don't look for another tool, and don't ask for a credential that would work. Present the draft, say it is waiting for someone to place the call from the dashboard inbox, and stop. You can still `outreach_revise_proposal`, `outreach_withdraw_proposal`, and `outreach_dismiss_proposal` on these — none of them send anything. + **Approving sends.** `outreach_approve_proposal` is not a status flip: for an `initial` proposal it creates the outbound conversation and sends the first email through the campaign's channel (appending the CTA link and unsubscribe footer per campaign settings); for a `reply` or `followup` it sends the draft verbatim on the existing conversation. There is no undo. Never approve in bulk without reading each draft. **Dismissing a follow-up stops the sequence.** A dismissed `followup` permanently ends the campaign's follow-up sequence for that contact — no later step will be drafted. That makes dismiss the right call for "stop chasing this person" and the wrong call for "reword this". For wording, revise the draft in place (below) or edit it in the dashboard review drawer, then approve the edited version. @@ -16,7 +18,7 @@ Every outbound email in Munin ships through a human-approved gate: curators file | Tool | Who it's for | What it means | |---|---|---| -| `outreach_approve_proposal` | operator | Send it. No undo. | +| `outreach_approve_proposal` | operator | Send it. No undo. Email only — voice and SMS are dashboard-only. | | `outreach_dismiss_proposal` | operator | *Rejected.* A judgement about this draft; on a `followup` it also stops the sequence. | | `outreach_revise_proposal` | agent | Same proposal, better text. Recipient and campaign are fixed. | | `outreach_withdraw_proposal` | agent | *Never mind* — the draft should not have been filed. Neutral. | @@ -41,18 +43,21 @@ Because withdrawal clears the pending slot for that (campaign, contact, kind), y ## In an MCP App host (Claude, Claude Desktop, …) -Call `outreach_list_proposals({ "status": "pending" })`. Hosts that support MCP Apps render the **Munin Inspector** panel (`ui://munin/inspector`) inline: one card per proposal with the contact, campaign, draft subject/body, and the curator's evidence, plus **Approve & send** and **Dismiss** buttons that call the decision tools directly. In these hosts the decision tools are **panel-only** (`_meta.ui.visibility: ["app"]`): they are hidden from you and only the operator's click can invoke them. Render the list and stop — the send decision is physically the human's. +Call `outreach_list_proposals({ "status": "pending" })`. Hosts that support MCP Apps render the **Munin Inspector** panel (`ui://munin/inspector`) inline: one card per proposal with the contact, campaign, draft subject/body, the curator's evidence, and a `delivery` line naming the address or phone number the approval would reach. In these hosts the decision tools are **panel-only** (`_meta.ui.visibility: ["app"]`): they are hidden from you and only the operator's click can invoke them. Render the list and stop — the send decision is physically the human's. + +Email proposals get **Approve & send** and **Dismiss** buttons. Voice and SMS proposals get **Dismiss** only, plus a line pointing the operator at the dashboard — the panel cannot place a call, because the server refuses any approval that does not come from a signed-in dashboard session. ## Without a panel -In hosts without MCP Apps support the decision tools appear normally, and the same flow works as plain tool calls: +In hosts without MCP Apps support the decision tools appear as ordinary tools, and the same flow works as plain tool calls — for **email campaigns only**: -1. **List** — `outreach_list_proposals({ "status": "pending" })`. Each row carries `id`, `kind`, `draftSubject`, `draftBody`, `evidence`, `proposedSendAt`, and nested `contact` / `campaign` summaries. -2. **Present each draft** to the operator: who it goes to, which campaign, the subject, the full body, and anything notable in `evidence`. Don't paraphrase the body — the operator is approving the literal text. -3. **Decide one at a time** on the operator's word: +1. **List** — `outreach_list_proposals({ "status": "pending" })`. Each row carries `id`, `kind`, `draftSubject`, `draftBody`, `evidence`, `proposedSendAt`, nested `contact` / `campaign` summaries, and `delivery`. +2. **Read `delivery` first.** It names the channel and the exact destination. If `delivery.channelType` is `voice` or `sms`, there is nothing for you to approve: present the draft and the number, tell the operator it is waiting in the dashboard inbox, and move on. If `delivery.destination` is `null`, the contact has no address or number on file and approval would fail — say so instead of trying. +3. **Present each draft** to the operator: who it goes to, which campaign, the subject, the full body, and anything notable in `evidence`. Don't paraphrase the body — the operator is approving the literal text. `delivery.appendsCta` / `appendsUnsubscribe` tell you what the system will add on top, so the operator isn't surprised by a footer they didn't read. +4. **Decide one at a time** on the operator's word: - `outreach_approve_proposal({ "id": "..." })` — sends immediately; the result carries `status: "sent"`, `conversationId`, `sentMessageId`. - `outreach_dismiss_proposal({ "id": "...", "reason": "..." })` — no send; the reason lands on the proposal for the curator's next pass. -4. **Handle refusals cleanly.** Both tools reject non-`pending` proposals (someone else may have decided it since listing — refresh rather than retry). Approval also rejects when the campaign was disabled or the contact became suppressed since drafting, and — for `followup` proposals — when the prospect replied after the draft was filed (dismiss it; the reply flow owns the conversation now). That is the suppression floor and stop-on-reply working, not an error to route around. +5. **Handle refusals cleanly.** Both tools reject non-`pending` proposals (someone else may have decided it since listing — refresh rather than retry). Approval also rejects when the campaign was disabled, when the contact became suppressed since drafting, when the campaign is inside its quiet hours or on a blackout date, and — for `followup` proposals — when the prospect replied after the draft was filed (dismiss it; the reply flow owns the conversation now). That is the safety floor working, not an error to route around. ## What not to do @@ -60,3 +65,4 @@ In hosts without MCP Apps support the decision tools appear normally, and the sa - **Don't revise-and-approve in one breath.** A revision resets what the operator needs to read. Revise, re-present the full body, then wait for their word. - **Don't withdraw a draft to escape a refusal.** If approval failed because the contact was suppressed or the prospect replied, that is the safety floor doing its job — dismiss it (or leave it) rather than withdrawing to clear the slot and re-drafting around the block. - **Don't loop approve over the whole list** ("approve all") unless the operator explicitly reviewed every draft and said exactly that. +- **Don't try to place a call.** If a voice or SMS approval is refused, that refusal is the product working as designed. There is no tool, key, or argument that makes it succeed — outbound calls and texts leave Munin only when a person clicks in the dashboard. diff --git a/packages/backend-core/src/modules/slack/slack-interactions.service.ts b/packages/backend-core/src/modules/slack/slack-interactions.service.ts index aab18114..01750c9d 100644 --- a/packages/backend-core/src/modules/slack/slack-interactions.service.ts +++ b/packages/backend-core/src/modules/slack/slack-interactions.service.ts @@ -15,7 +15,11 @@ import { ConvService } from '../conv/conv.service.ts'; import { ClaimedByOtherError, ConversationClaimsService } from '../conv/conv.claims.service.ts'; import { CrmInvalidError, CrmService } from '../crm/crm.service.ts'; import { KbConflictError, KbInvalidError, KbNotFoundError, KbService } from '../kb/kb.service.ts'; -import { OutreachInvalidError, OutreachService } from '../outreach/outreach.service.ts'; +import { + CHANNELS_REQUIRING_HUMAN_APPROVAL, + OutreachInvalidError, + OutreachService, +} from '../outreach/outreach.service.ts'; import { SlackApiClient } from './slack-api.client.ts'; import { SlackUserMappingService } from './slack-user-mapping.service.ts'; import { SlackService, decryptSecretValue } from './slack.service.ts'; @@ -248,6 +252,14 @@ export class SlackInteractionsService { return; case 'outreach_proposal': if (approve) { + const proposal = await this.outreach.getProposal(subject.subjectId); + const channelType = proposal.delivery?.channelType ?? 'email'; + if (CHANNELS_REQUIRING_HUMAN_APPROVAL.includes(channelType)) { + await ephemeral( + `:telephone_receiver: ${channelType === 'voice' ? 'Calls' : 'Text messages'} are approved in the Munin dashboard, not from Slack — open the inbox to review and send this one.`, + ); + return; + } await this.outreach.approveProposal(subject.subjectId, { publicBaseUrl: readApiBaseUrl(), }); diff --git a/packages/dashboard-pages/src/messages/en.json b/packages/dashboard-pages/src/messages/en.json index 41b54bcc..3a5a2508 100644 --- a/packages/dashboard-pages/src/messages/en.json +++ b/packages/dashboard-pages/src/messages/en.json @@ -1554,10 +1554,10 @@ "refresh": "Refresh", "refreshing": "Refreshing…", "noSubject": "(no subject)", + "dashboardOnlyCall": "Approving a call is done in the Munin dashboard — open the inbox to review and place this call to {destination}.", + "dashboardOnlySms": "Approving an SMS is done in the Munin dashboard — open the inbox to review and send this message to {destination}.", "noSubjectCall": "(spoken call — no subject)", "deliveryEmail": "Approving emails {destination}.", - "deliverySms": "Approving sends an SMS to {destination}.", - "deliveryCall": "Approving places a phone call to {destination} — the assistant opens with the draft above.", "deliveryNoEmail": "This contact has no email address on file — approving will fail.", "deliveryNoPhone": "This contact has no phone number on file — approving will fail.", "deliveryAppends": "Munin appends {items}.", @@ -1566,8 +1566,6 @@ "evidenceShow": "Evidence +", "evidenceHide": "Evidence −", "approve": "Approve & send", - "approveCall": "Approve & call", - "calling": "Calling…", "approving": "Sending…", "dismiss": "Dismiss", "dismissing": "Dismissing…", diff --git a/packages/dashboard-pages/src/messages/nb.json b/packages/dashboard-pages/src/messages/nb.json index 578030b0..dbe6d700 100644 --- a/packages/dashboard-pages/src/messages/nb.json +++ b/packages/dashboard-pages/src/messages/nb.json @@ -1554,10 +1554,10 @@ "refresh": "Oppdater", "refreshing": "Oppdaterer…", "noSubject": "(uten emne)", + "dashboardOnlyCall": "Samtaler godkjennes i Munin-dashbordet — åpne innboksen for å se over og ringe {destination}.", + "dashboardOnlySms": "SMS godkjennes i Munin-dashbordet — åpne innboksen for å se over og sende meldingen til {destination}.", "noSubjectCall": "(muntlig samtale — uten emne)", "deliveryEmail": "Godkjenning sender e-post til {destination}.", - "deliverySms": "Godkjenning sender SMS til {destination}.", - "deliveryCall": "Godkjenning ringer {destination} — assistenten åpner med utkastet over.", "deliveryNoEmail": "Denne kontakten har ingen e-postadresse registrert — godkjenning vil feile.", "deliveryNoPhone": "Denne kontakten har ikke noe telefonnummer registrert — godkjenning vil feile.", "deliveryAppends": "Munin legger til {items}.", @@ -1566,8 +1566,6 @@ "evidenceShow": "Grunnlag +", "evidenceHide": "Grunnlag −", "approve": "Godkjenn & send", - "approveCall": "Godkjenn & ring", - "calling": "Ringer…", "approving": "Sender…", "dismiss": "Avvis", "dismissing": "Avviser…", diff --git a/packages/inspector-app/src/views/proposals.tsx b/packages/inspector-app/src/views/proposals.tsx index c9a2d852..1accb574 100644 --- a/packages/inspector-app/src/views/proposals.tsx +++ b/packages/inspector-app/src/views/proposals.tsx @@ -15,6 +15,7 @@ const IDLE: CardState = { busy: null, error: null, decidedNow: false }; const DISPLAY_PAGE = 25; const REFRESH_LIMIT = 100; +const DASHBOARD_ONLY_CHANNELS = ['voice', 'sms']; export function ProposalsView({ app, initial }: { app: McpApp; initial: Proposal[] }) { const { t } = useI18n(); @@ -179,6 +180,7 @@ function ProposalRow({ const delivery = proposal.delivery; const isCall = delivery?.channelType === 'voice'; const destination = delivery?.destination ?? null; + const dashboardOnly = delivery ? DASHBOARD_ONLY_CHANNELS.includes(delivery.channelType) : false; return (
@@ -231,15 +233,15 @@ function ProposalRow({ {state.error &&

{state.error}

} {proposal.status === 'pending' ? (
- + {!dashboardOnly && ( + + )} @@ -267,19 +269,25 @@ function deliveryNotice(proposal: Proposal, t: Translator) {

); } - const key = - delivery.channelType === 'voice' - ? 'proposals.deliveryCall' - : delivery.channelType === 'sms' - ? 'proposals.deliverySms' - : 'proposals.deliveryEmail'; + if (DASHBOARD_ONLY_CHANNELS.includes(delivery.channelType)) { + return ( +

+ {t( + delivery.channelType === 'voice' + ? 'proposals.dashboardOnlyCall' + : 'proposals.dashboardOnlySms', + { destination: delivery.destination }, + )} +

+ ); + } const appended = [ delivery.appendsCta ? t('proposals.deliveryAppendsCta') : null, delivery.appendsUnsubscribe ? t('proposals.deliveryAppendsUnsubscribe') : null, ].filter(Boolean); return (

- {t(key, { destination: delivery.destination })} + {t('proposals.deliveryEmail', { destination: delivery.destination })} {appended.length > 0 && ` ${t('proposals.deliveryAppends', { items: appended.join(', ') })}`}

);