frontpage-host 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/CUSTOMER-INBOX.md +217 -0
- package/PEOPLE.md +103 -22
- package/dist/bin.js +3 -0
- package/dist/commands/customer-inbox.d.ts +14 -0
- package/dist/commands/customer-inbox.js +157 -0
- package/dist/commands/people-workspace.js +1 -2
- package/dist/commands/people.js +9 -1
- package/dist/registry.js +11 -2
- package/llms.txt +2 -0
- package/package.json +4 -3
package/AGENTS.md
CHANGED
|
@@ -256,3 +256,8 @@ frontpage doctor --json
|
|
|
256
256
|
## People groups, stages, rules, and imports
|
|
257
257
|
|
|
258
258
|
Use `frontpage people overview`, `groups`, `stages`, `membership`, `rules`, `attachments`, `import`, and `company`. Use `frontpage mail campaign` for one-off group/stage/list audiences in Emails. Every editor action is available through these commands or `frontpage people workspace OPERATION --file payload.json`. Full operations and examples: [PEOPLE.md](./PEOPLE.md); public guide: https://frontpage.host/developers/people. Automated emails select a saved template or series. Campaigns use their own drafts.
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
## Customer Inbox
|
|
262
|
+
|
|
263
|
+
Internal, allowlisted sites only. Use `frontpage customer-inbox get|put|post RESOURCE`, stable write keys and current versions. Full guide: [CUSTOMER-INBOX.md](./CUSTOMER-INBOX.md). `export CONVERSATION_ID --out FILE` writes authorized paginated history and file metadata; `download FILE_ID --out FILE` downloads through a file grant. Outputs never overwrite existing files. `frontpage usage` reports shared site credits, mail and storage. Human owners may handle conversations; website agents use `manage_customer_inbox` for setup/settings/widget/internal knowledge only, never a human CLI key to read or join customer threads.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Customer Inbox
|
|
2
|
+
|
|
3
|
+
Customer Inbox uses the site's existing AI credits and storage allowance. It is available on internal plans and approved pilot sites. The support agent uses fixed GPT-6 Luna and bills AI usage at 10× raw provider cost. Human handling does not call a model for each message.
|
|
4
|
+
|
|
5
|
+
Run `frontpage help customer-inbox`. Use the site's normal CLI key:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
frontpage customer-inbox get status --site my-site
|
|
9
|
+
frontpage customer-inbox get settings --site my-site
|
|
10
|
+
frontpage customer-inbox put settings --file ./settings.json --request-key settings_revision_1 --site my-site
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Reads use `get`. Writes use `put` or `post`, a UTF-8 JSON object file, and an 8–128 character request key. Reuse the same key and body after a failed or lost response. Changed bodies need new keys. The service rejects a key reused for a different request. Read current versions before changing records.
|
|
14
|
+
|
|
15
|
+
## Setup and settings
|
|
16
|
+
|
|
17
|
+
| Resource | Method | JSON body |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| `status` | GET | None |
|
|
20
|
+
| `activation` | PUT | `{"enabled":true}`; admin only |
|
|
21
|
+
| `address` | GET | Returns the current address, domain and policy version |
|
|
22
|
+
| `address` | PUT | `{"alias":"acme","policyVersion":"2026-10-13.1"}`; admin only |
|
|
23
|
+
| `address` | POST | `{"retire":true}`; admin only |
|
|
24
|
+
| `settings` | GET | Returns `version`, `value`, fixed model and multiplier |
|
|
25
|
+
| `settings` | PUT | `{"expectedVersion":0,"value":{"instructions":"Ask me if unsure."}}` |
|
|
26
|
+
|
|
27
|
+
Setup binds private guidance to the verified owner who requested it. That identity comes from Core, never model/customer fields. The agent manages new conversations by default after setup; the platform's rollout switches remain off until pilot verification passes. Set `value.enabled:false` for manual handling. Settings changes cancel stale automatic jobs and queued automatic sends.
|
|
28
|
+
|
|
29
|
+
Settings allow `enabled`, `instructions`, `tools`, `maxSteps` (1–12), `maxOutputTokens` (256–2400), and `maxInputBytes` (4000–64000). Supported tools: `knowledge_read`, `knowledge_update`, `customer_read`, `customer_update`, `notes_read`, `note_add`, `ask_owner`, `reply_customer`, `wait`, `finish`. Keep `wait` and `finish` enabled. Defaults: 8 steps, 1200 output tokens per call, 32000 bytes of public text. Model, billing multiplier and recipients are not caller-selectable fields. The worker also enforces platform concurrency, deadlines and input limits.
|
|
30
|
+
|
|
31
|
+
Aliases use 3–48 ASCII letters, numbers and single hyphens. The service normalizes case and checks collisions in one transaction. Reserved platform names and routing namespaces cannot be claimed. Retired addresses remain reserved; they never move to another site.
|
|
32
|
+
|
|
33
|
+
## Internal knowledge and approved sources
|
|
34
|
+
|
|
35
|
+
| Resource | Method | JSON body |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `knowledge` | GET | Recent notes; optional `?q=search` |
|
|
38
|
+
| `knowledge/<id>` | GET | One full note |
|
|
39
|
+
| `knowledge/<id>/history` | GET | Immutable revisions for that note |
|
|
40
|
+
| `knowledge` | PUT | `{"title":"Evening visits","body":"Ask the owner before promising an evening visit.","expectedVersion":0,"reason":"Owner policy"}` |
|
|
41
|
+
| `knowledge/<id>` | PUT | Current title/body, `expectedVersion`, `reason`, optional `locked` or `restoreVersion` |
|
|
42
|
+
| `sources` | GET | Approved source records |
|
|
43
|
+
| `sources` | PUT | `{"url":"https://example.com/help","enabled":true,"expectedVersion":0}`; fetches public text |
|
|
44
|
+
|
|
45
|
+
Supply `body` on a source write to save an owner-approved excerpt instead of fetching. Published sources refresh when the agent reads a source older than 24 hours. The fetcher reuses Core's DNS-pinned public HTTP infrastructure, rejects private addresses, checks redirects and bounds content. Sources over 16000 text characters require an owner-approved excerpt. No scripts run. A source version/digest fences knowledge updates against a changed source.
|
|
46
|
+
|
|
47
|
+
Knowledge search uses bounded literal keywords from natural-language queries and ranks notes by matched terms. Read the full matching note before updating it; update its current ID/version instead of creating a conflicting duplicate.
|
|
48
|
+
|
|
49
|
+
Lock a knowledge note with `locked:true`. The support agent cannot change locked notes or locks. Restore an older note with `restoreVersion`; the service creates a new revision and preserves the old history. The agent can change only internal knowledge, using verified owner guidance or a current approved source. It cannot edit public pages or articles.
|
|
50
|
+
|
|
51
|
+
## Human conversation handling
|
|
52
|
+
|
|
53
|
+
| Resource | Method | JSON body |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `conversations` | GET | Paginated list; `?limit=50&after=<id>` |
|
|
56
|
+
| `conversations/<id>` | GET | Thread and latest public message |
|
|
57
|
+
| `conversations/<id>/messages` | GET | `?after=0&limit=50&audience=all`; viewers get public history only |
|
|
58
|
+
| `conversations/<id>/routing` | GET | Current handler/version, People link and delivery states |
|
|
59
|
+
| `conversations/<id>/reply` | POST | `{"text":"Tuesday at 6:30 works. Keep staffing private.","handlerVersion":0,"privateGuidance":true}` |
|
|
60
|
+
| `conversations/<id>/control` | POST | `{"action":"takeover","handlerVersion":0}` or `{"action":"return","handlerVersion":1}` |
|
|
61
|
+
| `conversations/<id>/reply` | POST | `{"text":"Your quote is ready.","handlerVersion":1}`; requires current owner control |
|
|
62
|
+
| `files?messageId=<id>` | GET | Bound message files; editor/admin only |
|
|
63
|
+
| `files/<id>` | GET | Short-lived download grant; editor/admin only |
|
|
64
|
+
|
|
65
|
+
Private guidance stays in the same internal thread. The agent asks by a private email and stores its question in the history. Replying by email or UI gives context and leaves the agent responsible for the customer. The agent uses that context to answer and can update unlocked internal knowledge.
|
|
66
|
+
|
|
67
|
+
Email replies route through opaque, recipient-bound Reply-To addresses linked to the site, conversation, purpose, owner and handler version. Subjects and message text never choose a customer thread. The service checks current owner membership and sender authentication before accepting private owner mail. To take over by email, reply only `Take over this conversation`. The owner receives the customer-visible history and replies through Inbox. If history exceeds the email limit, the notification links to the full paginated history/export; it never claims a shortened transcript is complete. To return control, reply only `Agent, take it from here`. Mixed or unclear handoff text stays private for review. Customer and owner addresses are never added to a shared CC thread by Inbox.
|
|
68
|
+
|
|
69
|
+
Manual customer/owner exchanges use ordinary routing code. No support inference runs for every manual message. Returning control checks only eligible unanswered input and suppresses stale automatic sends.
|
|
70
|
+
|
|
71
|
+
Set `enabled:false` in settings to use manual handling for new conversations. The service binds that relay to the configured verified owner. Existing agent threads switch to manual handling on the next customer message or public owner reply. Re-enable the agent before returning a thread to it. Private guidance alone always stays private and never transfers control.
|
|
72
|
+
|
|
73
|
+
Set `guidanceToMe:true` in a versioned settings update to bind the signed current owner's verified address. Arbitrary recipient addresses and user IDs remain forbidden. The service saves only the resolved owner identity, not this setup flag.
|
|
74
|
+
|
|
75
|
+
## Security review and recovery
|
|
76
|
+
|
|
77
|
+
| Resource | Method | JSON body |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| `blocks` | GET | Site-scoped block evidence references; human editor/admin only |
|
|
80
|
+
| `blocks/<id>/unblock` | POST | `{"version":1}` |
|
|
81
|
+
| `jobs` | GET | Job state/reason and current `updatedAt` |
|
|
82
|
+
| `jobs/<id>/resume` | POST | `{"updatedAt":"<current ISO time>"}` for recoverable budget, receipt, rate or service pauses |
|
|
83
|
+
| `receipts` | GET | Held inbound mail; admin only |
|
|
84
|
+
| `receipts/<id>` | POST | `{}` queues a held capture retry; admin/pilot only |
|
|
85
|
+
|
|
86
|
+
Only a validated clear attack can create an automatic site-scoped block. Authenticated customer mail binds a verified email identity; unverified mail stays thread-scoped. Blocks never ban a People contact across sites or a shared IP. Unblock preserves evidence and does not replay the attack. Uncertain, malformed, refused or unavailable screening pauses the agent and leaves human handling available. Known blocks and rate/size checks stop work before another paid call.
|
|
87
|
+
|
|
88
|
+
The fixed policy requires allow probability at least 0.90 and confidence at least 0.80, with no attack category. Automatic blocking requires malicious probability and confidence at least 0.98 and an attack category. Other results pause for human review; a review does not label the customer malicious. These are platform controls, not customer settings. Review email notices deduplicate per conversation and reason group per hour. Saved receipt retries wait at least 30 seconds so one failed receipt cannot monopolize recovery.
|
|
89
|
+
|
|
90
|
+
Receipt recovery resubmits saved provider usage to the existing ledger. It never repeats inference. An interrupted request with unknown completion requires review. Resume cannot bypass screening, replay a blocked attack, or pretend unknown usage is zero. Exact input/output/cache/reasoning counts stay unchanged; the ledger records raw cost, fixed multiplier, customer debit, job/call/conversation references and immutable prices.
|
|
91
|
+
|
|
92
|
+
## Website-agent management boundary
|
|
93
|
+
|
|
94
|
+
The website agent uses `manage_customer_inbox` and `get_customer_inbox_docs`, with a signed manager principal bound to the owner run. It manages settings, tools, aliases and internal knowledge. It cannot read live threads or files, send messages, provide private guidance, take over/return, or review customer block evidence. Server checks enforce this even through indirect paths. Never give the website agent a human CLI/API key. Its manager tool uses no human keychain credential.
|
|
95
|
+
|
|
96
|
+
The developer guide lives at `https://frontpage.host/developers/customer-inbox`. Changes to that site use the Frontpage CLI pull/push/deploy/publish process.
|
|
97
|
+
|
|
98
|
+
## Owner workspace and widget
|
|
99
|
+
|
|
100
|
+
Open `/workspace/sites/<siteId>/customer-inbox` on an approved internal site. The workspace uses the same versioned service operations as the CLI. Conversation rows show the latest public message. Customer messages, agent replies, owner replies, private guidance, notes and handling changes have distinct labels. Private guidance stays in the thread and does not transfer ownership.
|
|
101
|
+
|
|
102
|
+
Use `frontpage customer-inbox get widget --site my-site` as a human admin to get the script URL, site ID and server-side app identity key. Install the returned script through the normal Frontpage CLI file workflow or your app's template:
|
|
103
|
+
|
|
104
|
+
```html
|
|
105
|
+
<script defer src="https://app.frontpage.host/customer-inbox/widget.js" data-site="SITE_UUID"></script>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The current scaffold and gateway already allow this HTTPS script and its requests. Installation uses their existing script support; it needs no gateway change, scaffold upgrade or fleet rebuild. The widget stays separate from the site bundle. It appears only when the service accepts that site's activation, rollout gates and approved origin.
|
|
109
|
+
|
|
110
|
+
Update settings using the current `expectedVersion`:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"expectedVersion": 4,
|
|
115
|
+
"value": {
|
|
116
|
+
"widget": {
|
|
117
|
+
"enabled": true,
|
|
118
|
+
"title": "Message our team",
|
|
119
|
+
"greeting": "How can we help?",
|
|
120
|
+
"color": "#7254d6",
|
|
121
|
+
"requireName": true,
|
|
122
|
+
"requireEmail": true,
|
|
123
|
+
"origins": ["https://my-site.frontpage.host", "https://app.example.com"]
|
|
124
|
+
},
|
|
125
|
+
"ownerCopies": false,
|
|
126
|
+
"helpRules": {
|
|
127
|
+
"humanRequest": true,
|
|
128
|
+
"missingAnswer": true,
|
|
129
|
+
"frustration": true,
|
|
130
|
+
"repeatedFailure": true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Use exact HTTPS origins without paths or credentials. This controls cross-origin browser access, not owner permissions. Required intake fields collect details; they do not prove email ownership. The customer must confirm the emailed link before email continuation or authenticated People access. Confirmation rotates the visitor credential. Recovery uses a new email proof and returns no history based on a typed address alone.
|
|
137
|
+
|
|
138
|
+
The agent reads help rules in its support prompt and uses `ask_owner`. Work caps remain server-enforced. Owner copies are private emails of future agent replies. Replying gives private guidance unless the owner explicitly requests takeover. Neither copies nor guidance put owner and customer on a shared CC thread.
|
|
139
|
+
|
|
140
|
+
### Signed app identity
|
|
141
|
+
|
|
142
|
+
Keep the `identityKey` returned by `widget` on your app server. Never include it in HTML, browser storage or a client bundle. Sign a short-lived assertion after authenticating your app user:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import { createHmac } from "node:crypto";
|
|
146
|
+
const now = Math.floor(Date.now() / 1000);
|
|
147
|
+
const claims = {
|
|
148
|
+
siteId: process.env.INBOX_SITE_ID,
|
|
149
|
+
origin: "https://app.example.com",
|
|
150
|
+
subject: authenticatedUser.id,
|
|
151
|
+
email: authenticatedUser.email,
|
|
152
|
+
name: authenticatedUser.name,
|
|
153
|
+
iat: now,
|
|
154
|
+
exp: now + 120
|
|
155
|
+
};
|
|
156
|
+
const encoded = Buffer.from(JSON.stringify(claims)).toString("base64url");
|
|
157
|
+
const identity = encoded + "." + createHmac("sha256", process.env.INBOX_IDENTITY_KEY)
|
|
158
|
+
.update(encoded).digest("hex");
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The service verifies the signature, site, origin and expiry. Assertions can last at most five minutes. They grant customer identity only, never owner or manager authority. Every app-session request must include the current signed identity matching that session's subject and email. Put a server-rendered assertion in `data-identity`, or preferably provide `data-identity-url="/api/support-identity"`. That same-origin HTTPS endpoint authenticates the current app user and returns `{"identity":"<signed assertion>"}`. The widget refreshes it; after logout or account changes, old app sessions cannot be read without the matching identity. Anonymous widgets need no assertion. Each approved origin has its own visitor session; email continues the bound conversation.
|
|
162
|
+
|
|
163
|
+
## Conversation state, exports and shared usage
|
|
164
|
+
|
|
165
|
+
| Resource | Method | Body or behavior |
|
|
166
|
+
| --- | --- | --- |
|
|
167
|
+
| `conversations?state=active` | GET | Open threads; also `closed`, `trash` or `all` |
|
|
168
|
+
| `conversations/<id>/messages?latest=true&limit=50&audience=all` | GET | Latest page in message order |
|
|
169
|
+
| `conversations/<id>/messages?before=<sequence>&limit=50&audience=all` | GET | Earlier page; do not combine with `after` or `latest` |
|
|
170
|
+
| `conversations/<id>/state` | POST | `{"action":"close","expectedVersion":0}`; actions: `close`, `reopen`, `trash`, `restore`, `read` |
|
|
171
|
+
| `conversations/<id>/state` | POST | Mark read with current state version and `"sequence":42` |
|
|
172
|
+
| `conversations/<id>/revoke-sessions` | POST | `{}`; revoke existing visitor credentials |
|
|
173
|
+
|
|
174
|
+
Trash retains history and file references. Restore reopens the thread; it does not erase prior handling decisions. State writes reject stale versions. Closing cancels queued sends and refuses unresolved provider outcomes. New customer input reopens a closed thread; trashed threads require owner restoration. Visitor revocation also disables recovery of that session.
|
|
175
|
+
|
|
176
|
+
Export the full authorized history and file manifest, one JSON record per line:
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
frontpage customer-inbox export CONVERSATION_UUID --out ./conversation.jsonl --site my-site
|
|
180
|
+
frontpage customer-inbox download FILE_UUID --out ./attachment.png --site my-site
|
|
181
|
+
frontpage usage --site my-site --json
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Export uses paginated reads rather than loading all messages into memory. Download uses an authorized file grant. Both commands refuse to overwrite an existing output file. Full private-history export and file access require human editor/admin authority. Viewers can read public history through the normal message resource.
|
|
185
|
+
|
|
186
|
+
Inbox sends count in the site's existing email allowance. Provider-accepted send receipts settle that usage once, including private owner notifications and copies. AI calls debit the site's existing credit ledger at 10× cost, with token counts unchanged. Storage uses the existing Blob scanner and counts actual attachments, originals and previews; conversation text adds no separate meter.
|
|
187
|
+
|
|
188
|
+
## File custody and cleanup
|
|
189
|
+
|
|
190
|
+
Email capture keeps the original MIME message, attachments and provider receipt. Email and chat image uploads keep the original file and a smaller agent preview. Repeated upload/message receipts retain the same file/message IDs. Validated raster customer images up to 12 megapixels use the existing public bucket. Larger or undecodable files remain downloadable and require human review. Other files and private mail originals use authenticated downloads over encrypted bytes in that same bucket. No Blob firewall changes or separate bucket are part of this release.
|
|
191
|
+
|
|
192
|
+
The existing backup process enumerates the whole bucket. The existing reaper scans Inbox files in bounded pages and asks Inbox for exact retained references; it does not read message bodies. Unknown reference results protect Inbox files while legacy cleanup continues. Live history, Trash, capture holds and original/preview links retain their files. The existing archive/restore/purge coordinator claims a file in Inbox before changing bytes. A claim blocks new links and remains held after an unknown outcome. Accepted email custody, message links, Trash and capture holds protect files; abandoned chat uploads can follow the existing grace/archive rules. Unknown file records stay protected. Site finalization freezes Inbox writes before moving its bytes through the same archive process. Broader shared-reaper performance work remains a later phase.
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
On app logout or account change, call `window.dispatchEvent(new Event("frontpage-inbox:reset"))`. This clears the displayed history, stored chat credential and pending input. The next request fetches a fresh assertion from `data-identity-url`; authenticated app sessions require the same current app identity on every request. Do not use a long-lived static assertion for an app with account switching. Verification returns to the same origin and the stored page path. Query strings and arbitrary redirect URLs are not accepted.
|
|
196
|
+
|
|
197
|
+
`jobs/<id>` reads one human-visible recovery state. Retry only the documented recoverable pauses using its current `updatedAt`; invalid screening and unknown sends require human review. An unknown shared-media operation keeps its `claimId`, `claimAction` and `lifecycleVersion`. Operators verify live/archive byte hashes and the existing archive row before acknowledging the matching service-only `/media-finish` operation. Never clear a claim solely because time passed. These endpoints accept only the `inbox-media` service actor; owner keys and website managers cannot submit them.
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
## Support agent tool contract
|
|
201
|
+
|
|
202
|
+
The support agent has its own system prompt plus the versioned private business instructions. Tool calls stay in Inbox's tenant, conversation, handler, configuration and job context. All ten tools start enabled; `wait` and `finish` cannot be removed. A disabled tool fails at the server.
|
|
203
|
+
|
|
204
|
+
| Tool | Inputs | Effect and limits |
|
|
205
|
+
| --- | --- | --- |
|
|
206
|
+
| `knowledge_read` | `query`, `id` or `sourceId` | Search internal notes or read complete approved evidence. |
|
|
207
|
+
| `knowledge_update` | Optional `id`; `title`, `body`, `expectedVersion`, `reason`, `evidenceKind`, `evidenceId`; source `evidenceVersion` | Update unlocked internal facts from verified guidance in this thread or an approved source revision. One-off guidance stays in the conversation. No public editing. |
|
|
208
|
+
| `customer_read` | None | Read only this thread's verified People contact. |
|
|
209
|
+
| `customer_update` | `name`/`phone`, `evidenceMessageId` | Enrich from an explicit customer statement; cannot change identity or permission. |
|
|
210
|
+
| `notes_read` | None | Read factual People notes for the bound contact. |
|
|
211
|
+
| `note_add` | `text` | Save a private factual action note in People, with an idempotent receipt. Do not store hidden reasoning. |
|
|
212
|
+
| `ask_owner` | `question` | Save the private question in the thread, email the verified configured owner and pause. Guidance does not take over. |
|
|
213
|
+
| `reply_customer` | `text` | Save a public answer and queue email to the bound verified customer when available. Never choose recipients. |
|
|
214
|
+
| `wait` | `reason` | Pause safely without sending. |
|
|
215
|
+
| `finish` | None | Complete this bounded job. |
|
|
216
|
+
|
|
217
|
+
Decisions screens malicious input only. It never chooses customer recipients, business answers, tools, owner handling or knowledge changes. Shared inference utilities provide the existing same-model credential/transport fallback; no expensive model substitution or customer model picker is added.
|
package/PEOPLE.md
CHANGED
|
@@ -338,18 +338,95 @@ Copy the attachment’s returned rule ID before enabling it. Pass `--enabled fal
|
|
|
338
338
|
|
|
339
339
|
See the [Email guide](/developers/mail) for content blocks, template variables, series step delays, sender settings, and email limits.
|
|
340
340
|
|
|
341
|
-
##
|
|
341
|
+
## Marketing email permission
|
|
342
342
|
|
|
343
|
-
|
|
343
|
+
Permission belongs to the email address and site. Groups and stages never grant it. A person who opts out stays in their groups and stages. Campaign selection, rules, direct-address sends, series and delivery enforce saved withdrawals and restrictions.
|
|
344
|
+
|
|
345
|
+
### Defaults and explicit choices
|
|
346
|
+
|
|
347
|
+
| Entry point | New contact | Existing contact |
|
|
348
|
+
| --- | --- | --- |
|
|
349
|
+
| Owner UI, `people add`, public API `POST /people`, agent `add_person` | `allowed` by default, recorded as an owner assertion | Omitted choice preserves the saved decision. Explicit choices can fill unknown permission; add cannot reverse a recorded refusal, mailing-list unsubscribe or suppression. |
|
|
350
|
+
| CSV import | Unknown unless an explicit list-wide choice or row value supplies permission | List-wide choices leave existing decisions alone. Row values can record permission, but cannot reverse a refusal, unsubscribe or suppression. |
|
|
351
|
+
| Forms, orders, bookings and account events | Unknown unless a supported subscriber signup records agreement | Preserve saved permission; purchases and signup timestamps are not email agreement. |
|
|
352
|
+
| Group, stage, name, phone, note and follow-up changes | No permission change | No permission change. Changing an email does not transfer an old address's grant; a recorded refusal stays in effect. |
|
|
353
|
+
|
|
354
|
+
The add choice is `allowed`, `unknown`, or `not_allowed`. API and agent callers use the same `emailPermission` string field. CLI callers use `--email-permission`. Recording `allowed` asserts that you have permission; it does not obtain permission from the person.
|
|
344
355
|
|
|
345
356
|
```
|
|
346
|
-
|
|
357
|
+
# New owner-added contacts default to permission given. Repeated adds preserve decisions.
|
|
358
|
+
frontpage people add --email alex@example.com --site YOUR_SITE --json
|
|
359
|
+
# Choose unknown when you do not have a recorded agreement:
|
|
360
|
+
frontpage people add --email pat@example.com --email-permission unknown --site YOUR_SITE --json
|
|
361
|
+
# Record refusal without enabling marketing:
|
|
362
|
+
frontpage people add --email sam@example.com --email-permission not_allowed --site YOUR_SITE --json
|
|
347
363
|
frontpage people membership get --person PERSON_ID --site YOUR_SITE --json
|
|
348
|
-
#
|
|
364
|
+
# Explicitly stop or record a new owner decision on the person:
|
|
365
|
+
frontpage people person permission --person PERSON_ID --allowed false --site YOUR_SITE --json
|
|
349
366
|
frontpage people person permission --person PERSON_ID --allowed true --site YOUR_SITE --json
|
|
350
367
|
```
|
|
351
368
|
|
|
352
|
-
`membership.get`
|
|
369
|
+
`membership.get` reports eligibility and the reason mail cannot send. A missing/invalid email, hidden person, missing permission, recorded refusal, subscriber unsubscribe or suppression can block marketing. An active subscriber agreement can establish permission even without a separate CRM flag. A requested test copy can go to its test recipient without a recorded grant or a new subscription. Saved withdrawals, hidden state and suppression still apply. Transactional confirmations keep their own existing sending flow.
|
|
370
|
+
|
|
371
|
+
An explicit address outside People is an owner-asserted recipient and does not create a CRM permission record. If that address matches a saved person, the normal CRM eligibility check applies. Known subscribers' unsubscribe and saved suppressions also apply when no CRM person matches. Once queued, a known person's ID stays attached so a later address change, hide or withdrawal prevents delivery. A test is not a way to send a customer campaign.
|
|
372
|
+
|
|
373
|
+
New CRM decisions store state, exact email, source, actor and recording time in `attrs.emailPermissionRecord`; the boolean `attrs.emailPermission` stays compatible with existing callers. Historical grants without that record remain supported. Permission events preserve the decision history. Do not add a permission condition to every rule; the sender already checks it.
|
|
374
|
+
|
|
375
|
+
### Repair reviewed historical permission without running rules
|
|
376
|
+
|
|
377
|
+
Review each person's current address, agreement evidence, saved withdrawals and restrictions first. This operation only fills missing permission. It never adds a person, changes their groups/stages, clears a restriction, or replays automation.
|
|
378
|
+
|
|
379
|
+
Save `repair.json` with real values:
|
|
380
|
+
|
|
381
|
+
```json
|
|
382
|
+
{
|
|
383
|
+
"personId": "PERSON_ID",
|
|
384
|
+
"allowed": true,
|
|
385
|
+
"backfill": true,
|
|
386
|
+
"expectedEmail": "alex@example.com",
|
|
387
|
+
"evidence": "Owner confirmed this person's marketing-email agreement on 2026-10-12",
|
|
388
|
+
"requestKey": "UNIQUE_REPAIR_UUID"
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
```
|
|
393
|
+
frontpage people person permission --file repair.json --site YOUR_SITE --json
|
|
394
|
+
frontpage people membership get --person PERSON_ID --site YOUR_SITE --json
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Reuse the same key and evidence after a lost response. A repeat returns `duplicate:true` without changing the decision again. If the person withdrew permission afterward, a retry does not restore it. A changed address, hidden person, existing decision, active subscriber agreement, unsubscribe or suppression refuses a new backfill. A new explicit owner decision uses the normal `allowed` operation instead; it still cannot reverse a subscriber unsubscribe or suppression.
|
|
398
|
+
|
|
399
|
+
For a reviewed cohort, submit at most 25 people in each bounded batch. Each row needs its own stable request key:
|
|
400
|
+
|
|
401
|
+
```json
|
|
402
|
+
{
|
|
403
|
+
"allowed": true,
|
|
404
|
+
"backfill": true,
|
|
405
|
+
"evidence": "Owner confirmed marketing-email permission for this reviewed cohort",
|
|
406
|
+
"people": [
|
|
407
|
+
{ "personId": "PERSON_ID", "expectedEmail": "alex@example.com", "requestKey": "UNIQUE_REPAIR_UUID" }
|
|
408
|
+
]
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Run the same `person permission --file reviewed-batch.json` command. Read every row in `results`: domain conflicts include `error` and `status`, while successful rows include `emailPermission` and `duplicate`. The batch can partly succeed; receipts make a retry safe. Malformed batch input fails before any permission writes. Infrastructure errors can leave completed receipts, so retry the exact batch rather than inventing new keys. This capability works through the public API and `manage_people` too; it has no company-only endpoint.
|
|
413
|
+
|
|
414
|
+
## Safe sync through public customer commands
|
|
415
|
+
|
|
416
|
+
Integrations read their own sources and write through the same CLI/API/agent operations as owners. No product database write credential is needed.
|
|
417
|
+
|
|
418
|
+
- `person.lookup` takes a valid `email` and/or `phone`, includes hidden matches, and refuses identities that point to different people. It never changes permission or unhides someone.
|
|
419
|
+
- `person.update` takes `personId`, a `patch` and optional `expected` contact values. `expected` supports exact name/email/phone values, including null. A changed value or hidden contact returns a conflict; it does not overwrite the owner's edit. Use `people workspace person.update --data` for this conditional operation.
|
|
420
|
+
- `notes.add` takes `personId`, `body`, optional `requestKey` (1–120 characters), and optional `historical` boolean. Reuse the key for the exact same note after a lost response. A changed person/body/history flag returns a conflict. A retry never restores a deleted note. Historical notes skip automation and record their processed receipt immediately.
|
|
421
|
+
- `membership.add` accepts optional `historical:true` to import a membership without replaying rules. Ordinary additions keep normal automation. Preserve an owner's removed membership rather than adding it again on every sync.
|
|
422
|
+
|
|
423
|
+
```sh
|
|
424
|
+
frontpage people workspace person.lookup --data '{"email":"alex@example.com"}' --site YOUR_SITE --json
|
|
425
|
+
frontpage people workspace person.update --data '{"personId":"PERSON_ID","patch":{"phone":"+12025550100"},"expected":{"phone":null}}' --site YOUR_SITE --json
|
|
426
|
+
frontpage people workspace notes.add --data '{"personId":"PERSON_ID","body":"Verified billing context","requestKey":"NOTE_UUID","historical":true}' --site YOUR_SITE --json
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Use a saved CSV import for a new historical cohort. Its list-wide permission choice applies only to new contacts; row values take priority and existing decisions remain saved. Check the real import status before writing notes or starting delivery. Source account or billing facts never imply marketing permission. Preserve hidden state and review identity conflicts rather than merging contacts.
|
|
353
430
|
|
|
354
431
|
## Import a customer list from CSV
|
|
355
432
|
|
|
@@ -364,7 +441,7 @@ Alex Rivera,alex@example.com,,
|
|
|
364
441
|
Phone customer,,+12025550199,
|
|
365
442
|
```
|
|
366
443
|
|
|
367
|
-
Map exact header names in `mapping.json`. The permission column is optional
|
|
444
|
+
Map exact header names in `mapping.json`. The permission column is optional. `yes`/`true` records an owner assertion and `no`/`false` records refusal. A supplied row value takes priority over the list-wide choice; duplicate rows keep a negative decision. Set top-level `emailPermission` to `allowed`, `unknown`, or `not_allowed` for new people whose row has no permission value. Omission defaults to unknown. List-wide choices never change existing decisions. Imports preserve saved refusals, unsubscribe and suppression. Changing the list choice requires a new request key.
|
|
368
445
|
|
|
369
446
|
```
|
|
370
447
|
{
|
|
@@ -376,7 +453,8 @@ Map exact header names in `mapping.json`. The permission column is optional; omi
|
|
|
376
453
|
},
|
|
377
454
|
"groupIds": [
|
|
378
455
|
"GROUP_ID"
|
|
379
|
-
]
|
|
456
|
+
],
|
|
457
|
+
"emailPermission": "unknown"
|
|
380
458
|
}
|
|
381
459
|
```
|
|
382
460
|
|
|
@@ -447,18 +525,6 @@ frontpage mail campaign get --id DRAFT_ID --site YOUR_SITE --json
|
|
|
447
525
|
|
|
448
526
|
Preview reports matched, eligible, no-email, suppressed, and permission-missing counts and sends nothing. Sending freezes content and eligible recipients, deduplicates email addresses, and uses the existing paced email queue. Reuse the send request key after a lost response. Check `campaign get` for queued, sent, pending, failed, and skipped work; queued does not mean delivered.
|
|
449
527
|
|
|
450
|
-
## Frontpage account sync
|
|
451
|
-
|
|
452
|
-
This admin-only feature adds Frontpage’s own account holders to the `landing` People workspace and keeps later signups in sync. It is not available to ordinary businesses. Businesses import their customer lists with CSV.
|
|
453
|
-
|
|
454
|
-
```
|
|
455
|
-
frontpage people company get --site landing --json
|
|
456
|
-
frontpage people company set --enabled true --site landing --json
|
|
457
|
-
frontpage people company set --enabled false --site landing --json
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
Sync uses saved batches and Frontpage user IDs to avoid duplicates. The backfill does not fire rules or grant email permission. Later signups can trigger group entry rules, subject to normal eligibility. Sync preserves owner edits. Pausing it leaves existing contacts saved. Supply an existing `groupId` through `--data` if you want a particular sync group.
|
|
461
|
-
|
|
462
528
|
## CLI, API, and agent reference
|
|
463
529
|
|
|
464
530
|
The editor, public API, CLI, and site agent call the same application service. Every operation below is available without opening the UI.
|
|
@@ -545,14 +611,12 @@ The agent can create and edit saved templates and series with the existing Email
|
|
|
545
611
|
| rules.runs | ruleId?, cursor?, limit? | Inspect saved outcomes and failed actions. |
|
|
546
612
|
| rules.retry | runId or ruleId | Retry eligible failed work; completed action receipts stay saved. |
|
|
547
613
|
| attachments.create | groupId, stageId?, emailKind, emailId, enabled?, name?, conditions? | Create an ordinary group/stage entry rule for a template or series. |
|
|
548
|
-
| person.permission
|
|
614
|
+
| person.permission | personId, allowed; backfill?, expectedEmail?, evidence?, requestKey?; or backfill batch with people (1–25) | Record a decision or repair reviewed missing permission without automation. Unsubscribe and suppression still win. |
|
|
549
615
|
| audience.preview | audience | Preview matched and eligible People for a campaign. |
|
|
550
616
|
| import.preview | csv, mapping?, headersOnly? | Validate a CSV and match existing contacts; sends nothing. |
|
|
551
617
|
| import.start | csv, mapping?, groupIds?, requestKey | Start a saved import. |
|
|
552
618
|
| import.get | id | Read the import cursor, outcomes, and errors. |
|
|
553
619
|
| import.retry | id | Retry failed rows after correcting saved identity conflicts. |
|
|
554
|
-
| company.get | — | Read the admin-only Frontpage account sync. |
|
|
555
|
-
| company.set | enabled, groupId? | Start or pause that sync in landing; platform admin only. |
|
|
556
620
|
|
|
557
621
|
## Limits, timing, and recovery
|
|
558
622
|
|
|
@@ -567,3 +631,20 @@ The agent can create and edit saved templates and series with the existing Email
|
|
|
567
631
|
**Keep scope clear.**
|
|
568
632
|
|
|
569
633
|
People does not replace the raw Inbox, Store orders, or the email send ledger. It combines the contact’s history and organization. Those source tools and their existing CLI commands remain available.
|
|
634
|
+
|
|
635
|
+
## Manual saved emails from People
|
|
636
|
+
|
|
637
|
+
Templates store reusable content; series store ordered templates and delays. Neither definition stores a customer audience. Their existing API/CLI sends and rule callers remain supported. In People, use **Email → Use a template** to create an independent campaign draft with the current audience. Review the draft before sending; editing it never changes its source template. A template's **Create campaign** shortcut copies saved content, then asks for a campaign audience.
|
|
638
|
+
|
|
639
|
+
The shared workspace operations are:
|
|
640
|
+
|
|
641
|
+
```
|
|
642
|
+
frontpage people workspace template.campaign --site YOUR_SITE --data '{"templateId":"TEMPLATE_ID","requestKey":"YOUR_STABLE_KEY","audience":{"personIds":["PERSON_ID"]}}' --json
|
|
643
|
+
frontpage people workspace series.preview --site YOUR_SITE --data '{"seriesId":"SERIES_ID","personIds":["PERSON_ID"]}' --json
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
To enrol, copy each returned batch's `personIds` and `reviewToken` into `series.enrol`, adding `seriesId` and a unique stable `requestKey`. Preview returns batches of five; the service accepts at most 25 per request. Reuse the original payload and key after a lost response. Results distinguish enrolled, already enrolled, skipped and failed. A new preview uses new keys. Never enrol or send without the user's explicit instruction.
|
|
647
|
+
|
|
648
|
+
Manual series starts use selected person IDs, an active series and the normal queue. Confirmation queues its first email; saved delays schedule later steps. The review lists permission exclusions and remaining allowance. It never activates a paused series or restarts an existing enrolment, including one bound to a person whose email changed. Delivery rechecks saved restrictions. Contact groups and permission stay unchanged.
|
|
649
|
+
|
|
650
|
+
People supplies first-name and unsubscribe values. If content uses order, booking or other caller-only variables, edit the independent campaign or choose another series. Do not invent values or send empty required context.
|
package/dist/bin.js
CHANGED
|
@@ -26,6 +26,7 @@ import { runExtensions } from "./commands/extensions.js";
|
|
|
26
26
|
import { runMedia } from "./commands/media.js";
|
|
27
27
|
import { runImages } from "./commands/images.js";
|
|
28
28
|
import { runInbox } from "./commands/inbox.js";
|
|
29
|
+
import { runCustomerInbox } from "./commands/customer-inbox.js";
|
|
29
30
|
import { runSeo } from "./commands/seo.js";
|
|
30
31
|
import { runRedirects } from "./commands/redirects.js";
|
|
31
32
|
import { runPeople } from "./commands/people.js";
|
|
@@ -117,6 +118,8 @@ async function main() {
|
|
|
117
118
|
return runImages(args);
|
|
118
119
|
case "inbox":
|
|
119
120
|
return runInbox(args);
|
|
121
|
+
case "customer-inbox":
|
|
122
|
+
return runCustomerInbox(args);
|
|
120
123
|
case "people":
|
|
121
124
|
return runPeople(args);
|
|
122
125
|
case "routines":
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function inboxCommand(args: string[]): {
|
|
2
|
+
method: string;
|
|
3
|
+
resource: string;
|
|
4
|
+
file: undefined;
|
|
5
|
+
key: undefined;
|
|
6
|
+
out: string;
|
|
7
|
+
} | {
|
|
8
|
+
method: string;
|
|
9
|
+
resource: string;
|
|
10
|
+
file: string | undefined;
|
|
11
|
+
key: string | undefined;
|
|
12
|
+
out: undefined;
|
|
13
|
+
};
|
|
14
|
+
export declare function runCustomerInbox(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFile, open, link, unlink } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { resolveSlug } from "../site.js";
|
|
4
|
+
import { takeFlag, assertNoUnknownFlags } from "../argv.js";
|
|
5
|
+
import { getApiKey } from "../auth/keychain.js";
|
|
6
|
+
import { apiBase, v1Get, httpErrorFromV1, CliHttpError, EXIT_AUTH, EXIT_USAGE, EXIT_OK, exitCodeForError, } from "../http/client.js";
|
|
7
|
+
import { emitOk, emitErr, isJson } from "../runtime.js";
|
|
8
|
+
const RESOURCE = /^(?:status|activation|address|widget|settings|knowledge(?:\/[0-9a-f-]+(?:\/history)?)?|sources|blocks(?:\/[0-9a-f-]+\/unblock)?|jobs(?:\/[0-9a-f-]+(?:\/resume)?)?|files(?:\/[0-9a-f-]+)?|receipts(?:\/[0-9a-f-]+)?|conversations(?:\/[0-9a-f-]+(?:\/(?:messages|routing|guidance|reply|control|state|revoke-sessions))?)?)(?:\?[^#]*)?$/;
|
|
9
|
+
export function inboxCommand(args) {
|
|
10
|
+
const work = [...args], verb = work.shift(), resource = work.shift(), file = takeFlag(work, "--file"), key = takeFlag(work, "--request-key"), out = takeFlag(work, "--out");
|
|
11
|
+
assertNoUnknownFlags(work, "customer-inbox");
|
|
12
|
+
if (verb === "export" || verb === "download") {
|
|
13
|
+
if (work.length ||
|
|
14
|
+
!resource ||
|
|
15
|
+
!out ||
|
|
16
|
+
file ||
|
|
17
|
+
key ||
|
|
18
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(resource))
|
|
19
|
+
throw new CliHttpError("Use customer-inbox export|download <id> --out <new-file>.", EXIT_USAGE, 400, "bad_request");
|
|
20
|
+
return {
|
|
21
|
+
method: verb.toUpperCase(),
|
|
22
|
+
resource,
|
|
23
|
+
file: undefined,
|
|
24
|
+
key: undefined,
|
|
25
|
+
out,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (out ||
|
|
29
|
+
work.length ||
|
|
30
|
+
!verb ||
|
|
31
|
+
!["get", "put", "post"].includes(verb) ||
|
|
32
|
+
!resource ||
|
|
33
|
+
!RESOURCE.test(resource))
|
|
34
|
+
throw new CliHttpError("Usage: frontpage customer-inbox get|put|post <resource> [--file <json>] [--request-key <key>] [--site <slug>]", EXIT_USAGE, 400, "bad_request");
|
|
35
|
+
if (verb !== "get" && (!file || !key || !/^[A-Za-z0-9_-]{8,128}$/.test(key)))
|
|
36
|
+
throw new CliHttpError("Writes require --file and an 8–128 character --request-key. Reuse both on retries.", EXIT_USAGE, 400, "bad_request");
|
|
37
|
+
if (verb === "get" && (file || key))
|
|
38
|
+
throw new CliHttpError("Reads do not take --file or --request-key.", EXIT_USAGE, 400, "bad_request");
|
|
39
|
+
return { method: verb.toUpperCase(), resource, file, key, out: undefined };
|
|
40
|
+
}
|
|
41
|
+
export async function runCustomerInbox(args) {
|
|
42
|
+
try {
|
|
43
|
+
const { slug, rest } = await resolveSlug(args), operation = inboxCommand(rest), path = `/api/v1/sites/${encodeURIComponent(slug)}/customer-inbox/${operation.resource}`;
|
|
44
|
+
let result;
|
|
45
|
+
if (operation.method === "EXPORT" || operation.method === "DOWNLOAD") {
|
|
46
|
+
const target = operation.out, temp = target + ".inbox-" + randomUUID() + ".tmp", handle = await open(temp, "wx", 0o600);
|
|
47
|
+
let count = 0;
|
|
48
|
+
try {
|
|
49
|
+
const prefix = `/api/v1/sites/${encodeURIComponent(slug)}/customer-inbox`;
|
|
50
|
+
if (operation.method === "DOWNLOAD") {
|
|
51
|
+
const record = await v1Get(`${prefix}/files/${operation.resource}`);
|
|
52
|
+
const response = await fetch(record.data.downloadUrl, {
|
|
53
|
+
redirect: "error",
|
|
54
|
+
signal: AbortSignal.timeout(60000),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok || !response.body)
|
|
57
|
+
throw new CliHttpError("Attachment download failed.", EXIT_USAGE, response.status, "download_failed");
|
|
58
|
+
for await (const chunk of response.body) {
|
|
59
|
+
count += chunk.byteLength;
|
|
60
|
+
if (count > 25 * 1024 * 1024)
|
|
61
|
+
throw new CliHttpError("Attachment exceeds 25 MB.", EXIT_USAGE, 413, "payload_too_large");
|
|
62
|
+
await handle.writeFile(chunk);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const conversation = await v1Get(`${prefix}/conversations/${operation.resource}`);
|
|
67
|
+
await handle.writeFile(JSON.stringify({ type: "conversation", value: conversation.data }) +
|
|
68
|
+
"\n");
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const page = await v1Get(`${prefix}/conversations/${operation.resource}/messages?after=${cursor}&limit=100&audience=all`);
|
|
72
|
+
for (const message of page.data.messages) {
|
|
73
|
+
const attachments = await v1Get(`${prefix}/files?messageId=${message.id}`);
|
|
74
|
+
await handle.writeFile(JSON.stringify({
|
|
75
|
+
type: "message",
|
|
76
|
+
value: message,
|
|
77
|
+
attachments: attachments.data,
|
|
78
|
+
}) + "\n");
|
|
79
|
+
count++;
|
|
80
|
+
}
|
|
81
|
+
if (page.data.nextCursor === null)
|
|
82
|
+
break;
|
|
83
|
+
if (page.data.nextCursor <= cursor)
|
|
84
|
+
throw new Error("History cursor did not advance.");
|
|
85
|
+
cursor = page.data.nextCursor;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await handle.sync();
|
|
89
|
+
await handle.close();
|
|
90
|
+
await link(temp, target);
|
|
91
|
+
await unlink(temp);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
await handle.close().catch(() => { });
|
|
95
|
+
await unlink(temp).catch(() => { });
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
result = {
|
|
99
|
+
path: target,
|
|
100
|
+
...(operation.method === "EXPORT"
|
|
101
|
+
? { messages: count, format: "jsonl" }
|
|
102
|
+
: { bytes: count }),
|
|
103
|
+
};
|
|
104
|
+
if (isJson())
|
|
105
|
+
emitOk("customer-inbox", result);
|
|
106
|
+
else
|
|
107
|
+
console.log(JSON.stringify(result, null, 2));
|
|
108
|
+
return EXIT_OK;
|
|
109
|
+
}
|
|
110
|
+
if (operation.method === "GET")
|
|
111
|
+
result = await v1Get(path, { timeoutMs: 20000 });
|
|
112
|
+
else {
|
|
113
|
+
const bytes = await readFile(operation.file);
|
|
114
|
+
if (bytes.length > 65536)
|
|
115
|
+
throw new CliHttpError("Inbox JSON exceeds 64 KiB.", EXIT_USAGE, 413, "payload_too_large");
|
|
116
|
+
let value;
|
|
117
|
+
try {
|
|
118
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new CliHttpError("Use a valid UTF-8 JSON object.", EXIT_USAGE, 400, "bad_request");
|
|
122
|
+
}
|
|
123
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
124
|
+
throw new CliHttpError("Use a JSON object.", EXIT_USAGE, 400, "bad_request");
|
|
125
|
+
const token = await getApiKey();
|
|
126
|
+
if (!token)
|
|
127
|
+
throw new CliHttpError("Not logged in. Run: frontpage login", EXIT_AUTH, 401);
|
|
128
|
+
const response = await fetch(apiBase() + path, {
|
|
129
|
+
method: operation.method,
|
|
130
|
+
headers: {
|
|
131
|
+
Authorization: `Bearer ${token}`,
|
|
132
|
+
"Content-Type": "application/json",
|
|
133
|
+
"Idempotency-Key": operation.key,
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify(value),
|
|
136
|
+
redirect: "error",
|
|
137
|
+
signal: AbortSignal.timeout(20000),
|
|
138
|
+
});
|
|
139
|
+
const body = (await response.json());
|
|
140
|
+
if (!response.ok)
|
|
141
|
+
throw httpErrorFromV1(response.status, body);
|
|
142
|
+
result = body;
|
|
143
|
+
}
|
|
144
|
+
if (isJson())
|
|
145
|
+
emitOk("customer-inbox", result);
|
|
146
|
+
else
|
|
147
|
+
console.log(JSON.stringify(result, null, 2));
|
|
148
|
+
return EXIT_OK;
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
if (isJson())
|
|
152
|
+
emitErr("customer-inbox", e);
|
|
153
|
+
else
|
|
154
|
+
console.error(e instanceof Error ? e.message : "Inbox request failed.");
|
|
155
|
+
return exitCodeForError(e);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -10,7 +10,6 @@ const AREAS = [
|
|
|
10
10
|
"import",
|
|
11
11
|
"attachments",
|
|
12
12
|
"person",
|
|
13
|
-
"company",
|
|
14
13
|
];
|
|
15
14
|
export function isPeopleWorkspaceCommand(sub) {
|
|
16
15
|
return (!!sub &&
|
|
@@ -23,7 +22,7 @@ export async function runPeopleWorkspace(slug, args) {
|
|
|
23
22
|
? work.shift()
|
|
24
23
|
: area === "overview" || area === "sources"
|
|
25
24
|
? area
|
|
26
|
-
: `${area}.${work.shift() ?? (area === "
|
|
25
|
+
: `${area}.${work.shift() ?? (area === "audience" ? "preview" : "list")}`;
|
|
27
26
|
const file = takeFlag(work, "--file"), json = takeFlag(work, "--data");
|
|
28
27
|
if (file && json)
|
|
29
28
|
throw new CliHttpError("Use --file or --data, not both.", EXIT_USAGE, 0, "bad_request");
|
package/dist/commands/people.js
CHANGED
|
@@ -161,6 +161,9 @@ export async function runPeople(args) {
|
|
|
161
161
|
const phone = takeFlag(work, "--phone");
|
|
162
162
|
const name = takeFlag(work, "--name");
|
|
163
163
|
const note = takeFlag(work, "--note");
|
|
164
|
+
const emailPermission = takeFlag(work, "--email-permission");
|
|
165
|
+
if (emailPermission !== undefined && !["allowed", "unknown", "not_allowed"].includes(emailPermission))
|
|
166
|
+
throw new CliHttpError("--email-permission is allowed, unknown, or not_allowed.", EXIT_USAGE, 0, "bad_request");
|
|
164
167
|
assertNoUnknownFlags(work, "people");
|
|
165
168
|
if (!email && !phone) {
|
|
166
169
|
console.error("Usage: frontpage people add --email you@example.com | --phone +15551234567 [--name …] [--note …]");
|
|
@@ -171,6 +174,7 @@ export async function runPeople(args) {
|
|
|
171
174
|
phone,
|
|
172
175
|
name,
|
|
173
176
|
note,
|
|
177
|
+
...(emailPermission !== undefined ? { emailPermission } : {}),
|
|
174
178
|
});
|
|
175
179
|
if (isJson()) {
|
|
176
180
|
emitOk("people", data);
|
|
@@ -228,12 +232,16 @@ export async function runPeople(args) {
|
|
|
228
232
|
const work = rest.slice(1);
|
|
229
233
|
const id = requireId(work);
|
|
230
234
|
const body = takeFlag(work, "--body") ?? takeFlag(work, "--note");
|
|
235
|
+
const requestKey = takeFlag(work, "--request-key");
|
|
236
|
+
const historical = takeFlag(work, "--historical");
|
|
237
|
+
if (historical !== undefined && !["true", "false"].includes(historical))
|
|
238
|
+
throw new Error("historical must be true or false.");
|
|
231
239
|
assertNoUnknownFlags(work, "people");
|
|
232
240
|
if (!body) {
|
|
233
241
|
console.error("Usage: frontpage people note <id> --body \"…\"");
|
|
234
242
|
return EXIT_USAGE;
|
|
235
243
|
}
|
|
236
|
-
const data = await v1Post(`${base}/${encodeURIComponent(id)}/events`, { body });
|
|
244
|
+
const data = await v1Post(`${base}/${encodeURIComponent(id)}/events`, { body, requestKey, ...(historical !== undefined ? { historical: historical === "true" } : {}) });
|
|
237
245
|
if (isJson()) {
|
|
238
246
|
emitOk("people", data);
|
|
239
247
|
return EXIT_OK;
|
package/dist/registry.js
CHANGED
|
@@ -407,6 +407,15 @@ export const COMMANDS = [
|
|
|
407
407
|
],
|
|
408
408
|
seeAlso: ["prompt", "help content", "help site-building"],
|
|
409
409
|
},
|
|
410
|
+
{
|
|
411
|
+
name: "customer-inbox",
|
|
412
|
+
summary: "Manage internal Customer Inbox settings, knowledge and human conversation handling",
|
|
413
|
+
usage: "frontpage customer-inbox get|put|post <resource> [--file <json>] [--request-key <key>] [--site <slug>] [--json]; export|download <id> --out <new-file>",
|
|
414
|
+
topics: ["content"],
|
|
415
|
+
examples: ["frontpage customer-inbox get settings", "frontpage customer-inbox put settings --file ./inbox-settings.json --request-key settings_revision_1", "frontpage customer-inbox get conversations", "frontpage customer-inbox get knowledge"],
|
|
416
|
+
flags: ["Internal plans and the site pilot apply", "Writes require a stable --request-key and a JSON file; reuse both on retries", "Human CLI access can guide, take over and reply; website-agent tools have management scope only", "Luna and 10× AI billing are fixed", "Full request examples: CUSTOMER-INBOX.md"],
|
|
417
|
+
seeAlso: ["people", "usage"],
|
|
418
|
+
},
|
|
410
419
|
{
|
|
411
420
|
name: "inbox",
|
|
412
421
|
summary: "List leads / subscribers / orders",
|
|
@@ -461,12 +470,12 @@ export const COMMANDS = [
|
|
|
461
470
|
"frontpage people export",
|
|
462
471
|
],
|
|
463
472
|
flags: [
|
|
464
|
-
"Workspace: groups list|get|create|update|archive|restore|preview; stages create|update|reorder|archive|restore; membership get|add|remove|move|stage|advance|override|hold; rules list|get|create|update|enable|pause|archive|validate|preview|runs|retry; attachments create; import preview|start|get|retry; person permission;
|
|
473
|
+
"Workspace: groups list|get|create|update|archive|restore|preview; stages create|update|reorder|archive|restore; membership get|add|remove|move|stage|advance|override|hold; rules list|get|create|update|enable|pause|archive|validate|preview|runs|retry; attachments create; import preview|start|get|retry; person permission; audience preview; overview; sources",
|
|
465
474
|
"All workspace operations accept --file JSON or --data JSON. Named flags: --id --label --name --group --stage --person --from-group --mode --color --position --terminal --enabled --hold --allowed --archived --reset --override include|exclude|auto --email-kind template|series --email-id --rule --run --cursor --limit --request-key. Boolean flags take true|false. Complex conditions/actions/mappings use JSON.",
|
|
466
475
|
"CSV: --csv PATH plus --file mapping.json. import start requires a stable --request-key. Retry the same import with the same key and contents.",
|
|
467
476
|
"list/export: --group GROUP_ID --stage STAGE_ID. See package PEOPLE.md for full examples and every JSON shape.",
|
|
468
477
|
"list: --q --status --source --form-slug --follow-up due|upcoming|none --hidden --sort --last-seen-days --tag --limit --cursor",
|
|
469
|
-
"add requires --email and/or --phone (never name alone)",
|
|
478
|
+
"add requires --email and/or --phone (never name alone). New contacts default to owner-asserted email permission; --email-permission allowed|unknown|not_allowed changes this choice. Existing default decisions stay saved; withdrawals/restrictions cannot be overridden by add.",
|
|
470
479
|
"tag: --add <label> | --remove <slug>",
|
|
471
480
|
"export writes UTF-8 CSV (same filters as list). Optional --out <file>",
|
|
472
481
|
"Hide is not forget. Unique email/phone stay; later ingest un-hides",
|
package/llms.txt
CHANGED
|
@@ -35,3 +35,5 @@
|
|
|
35
35
|
## People groups, stages, rules, and imports
|
|
36
36
|
|
|
37
37
|
Use `frontpage people overview`, `groups`, `stages`, `membership`, `rules`, `attachments`, `import`, and `company`. Use `frontpage mail campaign` for one-off group/stage/list audiences in Emails. Every editor action is available through these commands or `frontpage people workspace OPERATION --file payload.json`. Full operations and examples: [PEOPLE.md](./PEOPLE.md); public guide: https://frontpage.host/developers/people. Automated emails select a saved template or series. Campaigns use their own drafts.
|
|
38
|
+
|
|
39
|
+
Customer Inbox (internal sites): CUSTOMER-INBOX.md. Settings, widget, knowledge, human guidance/relay, lifecycle, export/download and recovery. Website agents configure through manage_customer_inbox and must not use human credentials for conversations.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frontpage-host",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Frontpage CLI
|
|
3
|
+
"version": "0.1.29",
|
|
4
|
+
"description": "Frontpage CLI \u2014 manage your site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"frontpage": "dist/bin.js"
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"README.md",
|
|
12
12
|
"AGENTS.md",
|
|
13
13
|
"llms.txt",
|
|
14
|
-
"PEOPLE.md"
|
|
14
|
+
"PEOPLE.md",
|
|
15
|
+
"CUSTOMER-INBOX.md"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"build": "tsc -p tsconfig.json",
|