frontpage-host 0.2.10 → 0.2.12

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 CHANGED
@@ -255,6 +255,8 @@ frontpage doctor --json
255
255
 
256
256
  ## People groups, stages, rules, and imports
257
257
 
258
+ New owner-added and CSV-imported contacts default to allowed for marketing. Explicit unknown/not_allowed choices and saved refusals, unsubscribes and delivery restrictions still apply. Imports keep their accepted policy on retry. `mail people --json` returns a specific `reasonCode` for unavailable recipients.
259
+
258
260
  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
261
 
260
262
 
@@ -273,3 +275,7 @@ Currency is owned by Payments and read by Store, Offerings and invoices. Offerin
273
275
  Customer Inbox setup: enable website chat through Inbox settings; Frontpage public sites load it without a file edit or publish. Other websites use the explicit widget snippet. `get website` reports managed addresses/public availability. Website knowledge is default-on, read-only public retrieval; `websiteKnowledge.enabled:false` disables it. Source toggles, attention filtering and receipt cursors use the documented versioned resources.
274
276
 
275
277
  Customer Inbox team guidance: administrators may set `guidanceEmail` through the versioned settings write. The owner toolbar has the same setting. Teammates reply as the configured mailbox; no teammate account or separate mailbox activation is needed. Do not set it to the public Inbox address. Conversation management uses close/reopen; do not use read/trash/restore.
278
+
279
+ ## Emails workspace workflows
280
+
281
+ See [MAIL.md](./MAIL.md) for the complete save contract, reusable people picker directory, saved campaign recipients, Off-series enrollment and resume timing, all sender settings/null resets, suppression rules, Sent queries and details, and retry-safe examples. `frontpage mail save --file save.json` saves template and pending series together; it never sends. `frontpage mail people` reads the same site-scoped directory as the workspace. Use `frontpage help mail --json` for current commands and flags. No new CLI binary, API namespace or migration command is required. Hosted guides and article help must be updated through the Frontpage CLI.
package/MAIL.md ADDED
@@ -0,0 +1,87 @@
1
+ # Emails: authoring, recipients and delivery
2
+
3
+ Use `frontpage help mail --json` for command discovery. The existing CLI binary and API namespace stay in place. Site roles and the CLI plan gate still apply. A viewer can read; an editor or owner can change email settings and content. These commands never grant platform admin rights or change a platform kill switch.
4
+
5
+ ## Save an email and its series together
6
+
7
+ `frontpage mail save --file save.json --site YOUR_SITE --json` calls `POST /api/v1/sites/YOUR_SITE/mail/save`.
8
+
9
+ ```json
10
+ {
11
+ "requestKey": "a-stable-key-for-this-save",
12
+ "document": {
13
+ "kind": "template",
14
+ "name": "Welcome",
15
+ "subject": "Welcome, {{first_name}}",
16
+ "preheader": "Here is your next step.",
17
+ "blocks": [{ "type": "paragraph", "text": "Thanks for joining us." }],
18
+ "variables": []
19
+ },
20
+ "series": { "name": "Welcome series", "steps": [] },
21
+ "append": true,
22
+ "delayMinutes": 1440
23
+ }
24
+ ```
25
+
26
+ This creates a shared template and an Off series with one step. It sends nothing and enrolls no one. The first step always has zero delay. Later step delays accept integer minutes from 0 through 525600; a series has at most 12 steps.
27
+
28
+ For an existing record, supply `id` and `expectedUpdatedAt` from `mail template get` / `mail email get` / `mail series get`. To edit an existing shared template from a series, send both the complete `document` and complete pending `series` in one save, omitting `append`. **Save & return saves both**, including pending names, steps, removals and waits. To add a new step, send a new template plus the source series and `append:true`.
29
+
30
+ The response reports `complete:true` and the saved records. Keep the exact payload and requestKey after a lost response. Repeating it returns the saved IDs without adding another template or step. Do not reuse a key for different content. A 409 means the saved version changed: retain the draft, read the current records, review the changes, and use a new key for the revised save. A failed transaction saves neither half. Subject, preview text, name and valid content are required. Saving never activates a series or sends email. Queued emails keep their saved content.
31
+
32
+ The workspace's Back button returns to the source series and keeps unfinished work. A test sends only to the signed-in account in the workspace, and does not complete a step task. CLI test recipients remain explicit and subject to the existing test restrictions.
33
+
34
+ ## Campaign recipients
35
+
36
+ Include `audience` with a one_off document in the same `mail save` request. Supported keys are `personIds`, `groupIds`, `stageIds`, `filters`, and `addresses`. Use `audience:null` to clear the saved selection. Addresses normalize to lower case. Saving addresses never creates CRM people or records consent. Recipient-only edits count as changes and save with the campaign.
37
+
38
+ For an audience-only change, the existing `mail campaign audience --id DOCUMENT_ID --file audience.json` remains supported. Its JSON is `{"audience":{"personIds":["PERSON_ID"]}}`, or `{"audience":null}` to clear. Saved selection is intent; final send review resolves groups and filters again, checks permission and delivery restrictions, and freezes the actual recipients and content. Started campaigns cannot be edited or sent twice with a new key. Read `PEOPLE.md` for preview/send review and stable send keys.
39
+
40
+ ## People directory and enrollment
41
+
42
+ ```bash
43
+ frontpage mail people --sort newest --page 1 --site YOUR_SITE --json
44
+ frontpage mail people --q alex --source signup --availability eligible --sort name --page 1 --site YOUR_SITE --json
45
+ frontpage mail people --selected PERSON_ID,OTHER_PERSON_ID --selected-only --page 1 --site YOUR_SITE --json
46
+ ```
47
+
48
+ Sort values: `newest`, `oldest`, `name`. Added order uses the CRM record's added date, not recent activity or the first historical event. Source values: form, booking, signup, payment, order. Availability: all, eligible, restricted. The response includes 20 rows per numbered page, total, added date, source and availability. Every query stays inside the authorized site. Search and availability apply before paging.
49
+
50
+ Use existing `frontpage people workspace series.preview --file preview.json` then `series.enrol --file batch.json` for reviewed CRM enrollment. Preview input: `{ "seriesId":"SERIES_ID", "personIds":["PERSON_ID"] }`. Each returned batch contains personIds and reviewToken; commit with those fields, seriesId and a stable requestKey. Reuse each batch's key after a network error. Receipts preserve partial outcomes. Enrollment saves immediately and never requires Save series afterward. Existing enrollment is deduplicated across all states; completed/cancelled people do not restart.
51
+
52
+ - **Off:** eligible people wait at step 1. Enrollment creates no queued message, first-template snapshot, due date or allowance reservation. No sender readiness or send meter is needed to record waiting people.
53
+ - **On:** the first email becomes ready, subject to normal permission, sender, kill switch, send window and allowance checks.
54
+ - **Turning Off:** pending series messages stop before provider handoff. A message already accepted by the provider cannot be recalled.
55
+ - **Turning On again:** new waiting people begin at step 1. Started people resume their next unsent step with its saved due date; an overdue step can send on the next pass. Each later wait starts after successful provider acceptance. Off time never skips a new person's first email or causes all later steps to send at once.
56
+
57
+ `mail series activate SERIES_ID` sets status active (On); `mail series pause SERIES_ID` sets paused (Off). Stored draft and paused both display Off. `mail series get/list` includes enrolledCount (waiting + current in progress/paused; excludes completed/cancelled) and waitingCount. `mail enrolments list` shows progress; cancel remains available while Off. The direct `mail send series` command also enrolls while Off and returns accepted enrollment IDs with no sends. Empty sends is not a delivery claim.
58
+
59
+ Selections with IDs use a read-only `POST /api/v1/sites/YOUR_SITE/mail/people` body to avoid URL limits. Ordinary directory queries use GET. Both call the same scoped query; neither writes CRM records.
60
+
61
+ ## Settings and suppressions
62
+
63
+ ```bash
64
+ frontpage mail settings set --header-align center --window-start 540 --window-end 1020 --hourly-cap 100 --site YOUR_SITE
65
+ frontpage mail settings set --file settings.json --site YOUR_SITE --json
66
+ ```
67
+
68
+ The settings JSON accepts exactly replyTo, postalLine, fromName, timezone, sendWindowStart, sendWindowEnd, hourlyCap and headerAlign. Omitted fields stay unchanged. Null clears replyTo, postalLine, fromName or hourlyCap. Window values are integer minutes after midnight (0–1439), hourlyCap is null or 1–1000, timezone is an IANA time zone, and headerAlign is left/center/right. A business postal address is still required for actual marketing sends. `--hourly-cap null` clears that cap. A settings save, content edit, read, cancellation or switch Off does not depend on the send meter.
69
+
70
+ `mail suppress list|add|rm` uses the existing site suppression service. Adding stops that site's marketing campaigns/series and cancels active enrollments. Transactional receipts and support replies follow separate rules. Removing a suppression does not restore consent, resubscribe someone or restart cancelled series.
71
+
72
+ ## Sent history
73
+
74
+ ```bash
75
+ frontpage mail sends list --q alex --status delivered --type campaign --page 1 --limit 20 --site YOUR_SITE --json
76
+ frontpage mail sends get SEND_ID --site YOUR_SITE --json
77
+ ```
78
+
79
+ Types: campaign, series, confirmation, template, test, inline. Search matches recipient/subject across all permitted rows. Status uses the stored ledger value (pending/claimed are queued). Count and numbered pages use the same site/filter scope, ordered by creation time then ID. Existing cursor pagination remains available; use either cursor or page. `createdAt` is queue creation, not delivery time. Details expose saved content, current status/error, attempts and source availability; preview sender/brand details use current settings. Deleted source records do not delete history. No resend/requeue action is added.
80
+
81
+ ## Internal agent parity
82
+
83
+ The owner agent uses the same services through save_mail_editor, get_mail_people, get_mail_send, update_mail_series, set_mail_series_steps, set_mail_settings and the existing send/preview/enrollment tools. remove_mail_suppression matches the CLI's existing authorized removal. Discuss and Build register the same Email capabilities. Tool schemas and runtime instructions describe complete saves, Off enrollment, waiting/resume timing, all settings/null resets, history filters and retry handling. The agent never takes site authority from model input.
84
+
85
+ Hosted developer guides and article bodies must be updated through the supported Frontpage CLI, using current base revisions. Never edit a customer site's Git branch or invent a second API version for this cutover.
86
+
87
+ The people directory returns `reasonCode`: `allowed`, `permission_missing`, `not_allowed`, `unsubscribed`, `delivery_restricted`, or `no_active_email`. These explain the shared marketing permission decision; selecting people never changes it. New owner additions and CSV imports default to allowed. Explicit refusals, unsubscribes and delivery restrictions still block sending.
package/PEOPLE.md CHANGED
@@ -235,7 +235,7 @@ The simple editor offers Purchases, Activity, and Contact details. Existing adva
235
235
  | set\_follow\_up | minutes | Set a follow-up after this many minutes. |
236
236
  | clear\_follow\_up | — | Clear the person’s follow-up. |
237
237
  | send\_template | templateId | Send a saved template if eligible. |
238
- | start\_series | seriesId | Start an active series with saved steps. |
238
+ | start\_series | seriesId | Enroll in a saved series. Off waits at step 1; On starts sending. |
239
239
  | cancel\_series | seriesId | Stop this group’s enrollment; requires a group/stage trigger. |
240
240
 
241
241
  A rule accepts 1–10 actions. Add/remove/move actions target manual groups; automatic groups use conditions and owner exceptions. `fromStageIds` prevents a late event from resetting someone who has moved further through the funnel.
@@ -301,7 +301,7 @@ Timed rules use minutes: 2880 is two days. They run through the worker, not a br
301
301
 
302
302
  ## Attach an email to a group or stage
303
303
 
304
- Use one action, **Attach an email**, then choose a saved email template or an active series. An attachment creates a normal editable rule on group entry or stage entry. Campaigns are one-off emails and cannot be selected for automation.
304
+ Use one action, **Attach an email**, then choose a saved email template or a series with saved steps. An Off series enrolls people waiting at step 1; turning it On starts them. An attachment creates a normal editable rule on group entry or stage entry. Campaigns are one-off emails and cannot be selected for automation.
305
305
 
306
306
  Save a reusable template as `welcome.json`:
307
307
 
@@ -347,7 +347,7 @@ Permission belongs to the email address and site. Groups and stages never grant
347
347
  | Entry point | New contact | Existing contact |
348
348
  | --- | --- | --- |
349
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. |
350
+ | CSV import | Allowed by default; an explicit list-wide choice or row value can select otherwise | List-wide choices leave existing decisions alone. Row values can record permission, but cannot reverse a refusal, unsubscribe or suppression. |
351
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
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
353
 
@@ -441,7 +441,7 @@ Alex Rivera,alex@example.com,,
441
441
  Phone customer,,+12025550199,
442
442
  ```
443
443
 
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.
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 allowed for new imports. The accepted import stores its effective policy, so retries keep the original choice. List-wide choices never change existing decisions. Imports preserve saved refusals, unsubscribe and suppression. Changing the list choice requires a new request key.
445
445
 
446
446
  ```
447
447
  {
@@ -454,7 +454,7 @@ Map exact header names in `mapping.json`. The permission column is optional. `ye
454
454
  "groupIds": [
455
455
  "GROUP_ID"
456
456
  ],
457
- "emailPermission": "unknown"
457
+ "emailPermission": "allowed"
458
458
  }
459
459
  ```
460
460
 
@@ -645,6 +645,6 @@ frontpage people workspace series.preview --site YOUR_SITE --data '{"seriesId":"
645
645
 
646
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
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.
648
+ Manual series enrollment uses selected person IDs and saved steps. Off records real enrollment waiting at step 1, without a send, snapshot, timer or allowance reservation; no second Save series is needed. On starts the first email subject to sender, permission, send window and allowance checks. Turning On resumes existing people at their next unsent step with its due date intact; later waits start after successful sends. Review returns status, exclusions and nullable remainingAllowance (null while Off). It never activates a series or restarts existing, completed or cancelled enrollment, including a person whose email changed. Delivery rechecks saved restrictions. Contact groups and permission stay unchanged. Campaign recipient intent also supports explicit addresses and null to clear; saving never sends or grants consent.
649
649
 
650
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/README.md CHANGED
@@ -90,3 +90,7 @@ frontpage store items get camp-hat --site my-site --json
90
90
  ```
91
91
 
92
92
  A whole stock count of `0` means sold out; `unlimited` sends JSON null. Omit stock on update to keep it. To rename existing option types, pass an `optionNames` list with the same number of names through `items update --file`. To return to one default SKU, pass `{"optionNames":[]}`. The existing first live variant supplies the regular price, sale price (including no sale), and stock (including unlimited). Inspect the live variants first and read back the result; other live variants are archived. Use `variants add` to turn options on. Do not recreate the product.
93
+
94
+ ## Emails workspace workflows
95
+
96
+ See [MAIL.md](./MAIL.md) for the complete save contract, reusable people picker directory, saved campaign recipients, Off-series enrollment and resume timing, all sender settings/null resets, suppression rules, Sent queries and details, and retry-safe examples. `frontpage mail save --file save.json` saves template and pending series together; it never sends. `frontpage mail people` reads the same site-scoped directory as the workspace. Use `frontpage help mail --json` for current commands and flags. No new CLI binary, API namespace or migration command is required. Hosted guides and article help must be updated through the Frontpage CLI.
@@ -4,12 +4,13 @@ import { resolveSlug } from "../site.js";
4
4
  import { CliHttpError, EXIT_OK, EXIT_USAGE, exitCodeForError, v1Delete, v1Get, v1Patch, v1Post, v1Put, } from "../http/client.js";
5
5
  import { assertNoUnknownFlags, hasFlag, takeFlag } from "../argv.js";
6
6
  import { emitErr, emitOk, isJson } from "../runtime.js";
7
- const USAGE = "Usage: frontpage mail settings|email|template|series|send|sends|enrolments|suppress … [--site <slug>] [--json]";
7
+ const USAGE = "Usage: frontpage mail settings|email|template|series|save|people|send|sends|enrolments|suppress … [--site <slug>] [--json]";
8
8
  const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
9
  function requireId(work, kind) {
10
10
  const fromFlag = takeFlag(work, "--id");
11
11
  const positional = work.find((a) => !a.startsWith("-"));
12
- const raw = fromFlag ?? (positional ? work.splice(work.indexOf(positional), 1)[0] : undefined);
12
+ const raw = fromFlag ??
13
+ (positional ? work.splice(work.indexOf(positional), 1)[0] : undefined);
13
14
  if (!raw || !ID_RE.test(raw)) {
14
15
  throw new CliHttpError(`${kind} not found.`, EXIT_USAGE, 404, "not_found");
15
16
  }
@@ -35,7 +36,10 @@ function parseVars(pairs) {
35
36
  return vars;
36
37
  }
37
38
  function parseRecipients(work) {
38
- const tos = takeAll(work, "--to").flatMap((v) => v.split(",").map((s) => s.trim()).filter(Boolean));
39
+ const tos = takeAll(work, "--to").flatMap((v) => v
40
+ .split(",")
41
+ .map((s) => s.trim())
42
+ .filter(Boolean));
39
43
  const name = takeFlag(work, "--name");
40
44
  const vars = parseVars(takeAll(work, "--var"));
41
45
  if (tos.length === 0) {
@@ -83,6 +87,45 @@ export async function runMail(args) {
83
87
  if (group === "campaign")
84
88
  return await runCampaign(slug, rest.slice(1));
85
89
  const base = `/api/v1/sites/${encodeURIComponent(slug)}/mail`;
90
+ if (group === "save") {
91
+ const work = rest.slice(1), file = takeFlag(work, "--file");
92
+ assertNoUnknownFlags(work, "mail");
93
+ if (!file)
94
+ throw new CliHttpError("Use mail save --file save.json. Keep its requestKey and payload unchanged for retries.", EXIT_USAGE, 0, "bad_request");
95
+ const data = await v1Post(`${base}/save`, await readJsonFile(file));
96
+ if (isJson())
97
+ emitOk("mail", data);
98
+ else
99
+ console.log("Email and series changes saved.");
100
+ return EXIT_OK;
101
+ }
102
+ if (group === "people") {
103
+ const work = rest.slice(1), params = new URLSearchParams();
104
+ for (const flag of [
105
+ "q",
106
+ "source",
107
+ "sort",
108
+ "availability",
109
+ "page",
110
+ "selected",
111
+ ]) {
112
+ const value = takeFlag(work, `--${flag}`);
113
+ if (value !== undefined)
114
+ params.set(flag, value);
115
+ }
116
+ if (hasFlag(work, "--selected-only"))
117
+ params.set("selectedOnly", "true");
118
+ assertNoUnknownFlags(work, "mail");
119
+ const data = params.has("selected")
120
+ ? await v1Post(`${base}/people`, Object.fromEntries(params))
121
+ : await v1Get(`${base}/people?${params}`);
122
+ if (isJson())
123
+ emitOk("mail", data);
124
+ else
125
+ for (const p of (data.people ?? []))
126
+ console.log(`${p.id} ${p.email ?? ""} ${p.eligible ? "eligible" : (p.reasonCode ?? "restricted").replaceAll("_", " ")}`);
127
+ return EXIT_OK;
128
+ }
86
129
  if (group === "settings") {
87
130
  const sub = rest[1] ?? "get";
88
131
  const work = rest.slice(2);
@@ -102,12 +145,29 @@ export async function runMail(args) {
102
145
  const replyTo = takeFlag(work, "--reply-to");
103
146
  const fromName = takeFlag(work, "--from-name");
104
147
  const timezone = takeFlag(work, "--timezone");
148
+ const file = takeFlag(work, "--file"), headerAlign = takeFlag(work, "--header-align"), start = takeFlag(work, "--window-start"), end = takeFlag(work, "--window-end"), cap = takeFlag(work, "--hourly-cap");
149
+ const extra = file ? await readJsonFile(file) : {};
150
+ if (headerAlign !== undefined)
151
+ extra.headerAlign = headerAlign;
152
+ if (start !== undefined)
153
+ extra.sendWindowStart = Number(start);
154
+ if (end !== undefined)
155
+ extra.sendWindowEnd = Number(end);
156
+ if (cap !== undefined) {
157
+ if (cap !== "null" &&
158
+ (!Number.isInteger(Number(cap)) ||
159
+ Number(cap) < 1 ||
160
+ Number(cap) > 1000))
161
+ throw new CliHttpError("Hourly cap must be an integer from 1 to 1000, or null.", EXIT_USAGE, 0, "bad_request");
162
+ extra.hourlyCap = cap === "null" ? null : Number(cap);
163
+ }
105
164
  assertNoUnknownFlags(work, "mail");
106
165
  const data = await v1Patch(`${base}/settings`, {
107
- postalLine: postal,
108
- replyTo,
109
- fromName,
110
- timezone,
166
+ ...extra,
167
+ ...(postal !== undefined ? { postalLine: postal } : {}),
168
+ ...(replyTo !== undefined ? { replyTo } : {}),
169
+ ...(fromName !== undefined ? { fromName } : {}),
170
+ ...(timezone !== undefined ? { timezone } : {}),
111
171
  });
112
172
  if (isJson())
113
173
  emitOk("mail", data);
@@ -222,7 +282,8 @@ export async function runMail(args) {
222
282
  if (isJson())
223
283
  emitOk("mail", data);
224
284
  else
225
- console.log((data.preview ?? {}).subject ?? "Preview ready.");
285
+ console.log((data.preview ?? {}).subject ??
286
+ "Preview ready.");
226
287
  return EXIT_OK;
227
288
  }
228
289
  }
@@ -256,7 +317,9 @@ export async function runMail(args) {
256
317
  assertNoUnknownFlags(work, "mail");
257
318
  if (!name)
258
319
  throw new CliHttpError("Usage: frontpage mail series create --name …", EXIT_USAGE, 0, "bad_request");
259
- const data = await v1Post(`${base}/series`, { name });
320
+ const data = await v1Post(`${base}/series`, {
321
+ name,
322
+ });
260
323
  if (isJson())
261
324
  emitOk("mail", data);
262
325
  else
@@ -310,7 +373,11 @@ export async function runMail(args) {
310
373
  let steps = [];
311
374
  if (file) {
312
375
  const body = await readJsonFile(file);
313
- steps = Array.isArray(body.steps) ? body.steps : Array.isArray(body) ? body : [];
376
+ steps = Array.isArray(body.steps)
377
+ ? body.steps
378
+ : Array.isArray(body)
379
+ ? body
380
+ : [];
314
381
  }
315
382
  else {
316
383
  steps = stepFlags.map(parseStepFlag);
@@ -350,8 +417,14 @@ export async function runMail(args) {
350
417
  const id = requireId(restKind, kind === "series" ? "Series" : "Document");
351
418
  const recipients = parseRecipients(restKind);
352
419
  assertNoUnknownFlags(restKind, "mail");
353
- const path = kind === "series" ? `${base}/series/${id}/send` : `${base}/${kind === "email" ? "emails" : "templates"}/${id}/send`;
354
- const data = await v1Post(path, { recipients, wait, test });
420
+ const path = kind === "series"
421
+ ? `${base}/series/${id}/send`
422
+ : `${base}/${kind === "email" ? "emails" : "templates"}/${id}/send`;
423
+ const data = await v1Post(path, {
424
+ recipients,
425
+ wait,
426
+ test,
427
+ });
355
428
  if (isJson())
356
429
  emitOk("mail", data);
357
430
  else
@@ -365,10 +438,16 @@ export async function runMail(args) {
365
438
  const work = rest.slice(2);
366
439
  if (sub === "list") {
367
440
  const batchId = takeFlag(work, "--batch");
441
+ const filters = new URLSearchParams();
442
+ for (const flag of ["q", "status", "type", "page"]) {
443
+ const value = takeFlag(work, `--${flag}`);
444
+ if (value !== undefined)
445
+ filters.set(flag, value);
446
+ }
368
447
  const limit = takeFlag(work, "--limit");
369
448
  const cursor = takeFlag(work, "--cursor");
370
449
  assertNoUnknownFlags(work, "mail");
371
- const params = new URLSearchParams();
450
+ const params = filters;
372
451
  if (batchId)
373
452
  params.set("batchId", batchId);
374
453
  if (limit)
@@ -402,8 +481,13 @@ export async function runMail(args) {
402
481
  const work = rest.slice(2);
403
482
  if (sub === "list") {
404
483
  const seriesId = takeFlag(work, "--series");
405
- assertNoUnknownFlags(work, "mail");
406
484
  const params = new URLSearchParams();
485
+ for (const name of ["q", "offset", "limit"]) {
486
+ const value = takeFlag(work, `--${name}`);
487
+ if (value !== undefined)
488
+ params.set(name, value);
489
+ }
490
+ assertNoUnknownFlags(work, "mail");
407
491
  if (seriesId)
408
492
  params.set("seriesId", seriesId);
409
493
  const qs = params.toString();
@@ -437,7 +521,8 @@ export async function runMail(args) {
437
521
  if (isJson())
438
522
  emitOk("mail", data);
439
523
  else {
440
- const rows = data.suppressions ?? [];
524
+ const rows = data.suppressions ??
525
+ [];
441
526
  for (const row of rows)
442
527
  console.log(`${row.email ?? ""} ${row.reason ?? ""}`);
443
528
  }
@@ -28,6 +28,7 @@ _frontpage_completions() {
28
28
  people) COMPREPLY=( $(compgen -W "list get add set note rm-note follow-up hide unhide" -- "\${cur}") ) ;;
29
29
  routines) COMPREPLY=( $(compgen -W "list get add set pause resume rm run runs" -- "\${cur}") ) ;;
30
30
  buttons) COMPREPLY=( $(compgen -W "list get create update archive restore" -- "\${cur}") ) ;;
31
+ mail) COMPREPLY=( $(compgen -W "save people campaign settings email template series send sends enrolments suppress --file --q --status --type --page --limit --cursor --source --sort --availability --selected --selected-only --offset --header-align --window-start --window-end --hourly-cap --site --json" -- "\${cur}") ) ;;
31
32
  payments) COMPREPLY=( $(compgen -W "status settings connect availability activity buttons tax invoices recurring customers --file --operation-key --preview-hash --person --email --description --amount-cents --cadence --first-date --time-zone --due-days --no-open --site --json --yes" -- "\${cur}") ) ;;
32
33
  store) COMPREPLY=( $(compgen -W "items variants categories settings orders import" -- "\${cur}") ) ;;
33
34
  modules)
@@ -74,6 +75,7 @@ _frontpage() {
74
75
  analytics) _values 'subcommand' dashboard summary query funnel timeseries compare sql ;;
75
76
  people) _values 'subcommand' list get add set note rm-note follow-up hide unhide ;;
76
77
  routines) _values 'subcommand' list get add set pause resume rm run runs ;;
78
+ mail) _values 'subcommand' save people campaign settings email template series send sends enrolments suppress ;;
77
79
  buttons) _values 'subcommand' list get create update archive restore ;;
78
80
  payments) _values 'subcommand' status settings connect availability activity buttons tax invoices recurring customers ;;
79
81
  store) _values 'subcommand' items variants categories settings orders import ;;
@@ -114,6 +116,7 @@ export function generateFishCompletion() {
114
116
  `complete -c frontpage -n "__fish_seen_subcommand_from analytics" -a "dashboard summary query funnel timeseries compare sql"`,
115
117
  `complete -c frontpage -n "__fish_seen_subcommand_from people" -a "list get add set note rm-note follow-up hide unhide"`,
116
118
  `complete -c frontpage -n "__fish_seen_subcommand_from routines" -a "list get add set pause resume rm run runs"`,
119
+ `complete -c frontpage -n "__fish_seen_subcommand_from mail" -a "save people campaign settings email template series send sends enrolments suppress"`,
117
120
  `complete -c frontpage -n "__fish_seen_subcommand_from buttons" -a "list get create update archive restore"`,
118
121
  `complete -c frontpage -n "__fish_seen_subcommand_from payments" -a "status settings connect availability activity buttons tax invoices recurring customers"
119
122
  complete -c frontpage -n "__fish_seen_subcommand_from store" -a "items variants categories settings orders import"`,
package/dist/registry.js CHANGED
@@ -38,9 +38,9 @@ export const COMMANDS = [
38
38
  summary: "Save an API key to the OS keychain",
39
39
  usage: "frontpage login [api-key]",
40
40
  topics: ["auth"],
41
- examples: ['frontpage login', 'frontpage login "$FRONTPAGE_API_KEY"'],
41
+ examples: ["frontpage login", 'frontpage login "$FRONTPAGE_API_KEY"'],
42
42
  flags: [
43
- "FRONTPAGE_API_KEY env overrides keychain after login for that process"
43
+ "FRONTPAGE_API_KEY env overrides keychain after login for that process",
44
44
  ],
45
45
  seeAlso: ["whoami", "doctor", "logout"],
46
46
  },
@@ -71,7 +71,7 @@ export const COMMANDS = [
71
71
  topics: ["auth", "sync"],
72
72
  examples: ["frontpage use arbiter"],
73
73
  dangers: [
74
- "Clears baseCommitSha when switching sites — run pull again before push"
74
+ "Clears baseCommitSha when switching sites — run pull again before push",
75
75
  ],
76
76
  seeAlso: ["link", "pull", "status"],
77
77
  },
@@ -207,7 +207,7 @@ export const COMMANDS = [
207
207
  examples: [
208
208
  "frontpage open",
209
209
  "frontpage open /updates",
210
- "frontpage open --path /pricing"
210
+ "frontpage open --path /pricing",
211
211
  ],
212
212
  flags: [
213
213
  "Optional site-relative path appended to https://<slug>.frontpage.host",
@@ -424,14 +424,14 @@ export const COMMANDS = [
424
424
  "frontpage customer-inbox get settings",
425
425
  "frontpage customer-inbox put settings --file ./inbox-settings.json --request-key settings_revision_1",
426
426
  "frontpage customer-inbox get conversations",
427
- "frontpage customer-inbox get knowledge"
427
+ "frontpage customer-inbox get knowledge",
428
428
  ],
429
429
  flags: [
430
430
  "Internal plans and the site pilot apply",
431
431
  "Writes require a stable --request-key and a JSON file; reuse both on retries",
432
432
  "Human CLI access can guide, take over and reply; website-agent tools have management scope only",
433
433
  "Luna and 10× AI billing are fixed",
434
- "Full request examples: CUSTOMER-INBOX.md"
434
+ "Full request examples: CUSTOMER-INBOX.md",
435
435
  ],
436
436
  seeAlso: ["people", "usage"],
437
437
  },
@@ -449,7 +449,7 @@ export const COMMANDS = [
449
449
  topics: ["content"],
450
450
  examples: [
451
451
  "frontpage routines list",
452
- "frontpage routines add --cadence weekly --weekdays tue,thu,fri --hours 8am,12pm,4pm --timezone America/Chicago --name \"Weekday check\" --prompt \"Review new form submissions and reply to each customer.\"",
452
+ 'frontpage routines add --cadence weekly --weekdays tue,thu,fri --hours 8am,12pm,4pm --timezone America/Chicago --name "Weekday check" --prompt "Review new form submissions and reply to each customer."',
453
453
  "frontpage routines pause <id>",
454
454
  "frontpage routines resume <id>",
455
455
  "frontpage routines run <id>",
@@ -480,8 +480,8 @@ export const COMMANDS = [
480
480
  "frontpage people list",
481
481
  "frontpage people add --email alex@example.com --name Alex",
482
482
  "frontpage people set <id> --status working",
483
- "frontpage people set <id> --line1 \"12 Main\" --city Austin --region TX --postal 78701 --country US",
484
- "frontpage people note <id> --body \"Called them\"",
483
+ 'frontpage people set <id> --line1 "12 Main" --city Austin --region TX --postal 78701 --country US',
484
+ 'frontpage people note <id> --body "Called them"',
485
485
  "frontpage people follow-up <id> --at 2026-09-12T00:00:00Z",
486
486
  "frontpage people hide <id>",
487
487
  "frontpage people tag <id> --add vip",
@@ -491,7 +491,7 @@ export const COMMANDS = [
491
491
  flags: [
492
492
  "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",
493
493
  "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.",
494
- "CSV: --csv PATH plus --file mapping.json. import start requires a stable --request-key. Retry the same import with the same key and contents.",
494
+ "CSV: --csv PATH plus --file mapping.json. New contacts default to allowed; JSON emailPermission can select unknown or not_allowed. Row values override the list choice; saved refusals, unsubscribes and suppressions remain protected. import start requires a stable --request-key. Retry the same import with the same key and contents.",
495
495
  "list/export: --group GROUP_ID --stage STAGE_ID. See package PEOPLE.md for full examples and every JSON shape.",
496
496
  "list: --q --status --source --form-slug --follow-up due|upcoming|none --hidden --sort --last-seen-days --tag --limit --cursor",
497
497
  "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.",
@@ -573,10 +573,10 @@ export const COMMANDS = [
573
573
  {
574
574
  name: "mail",
575
575
  summary: "Emails: campaign drafts, People audiences, templates, series, and sends",
576
- usage: "frontpage mail settings|email|template|series|campaign|send|sends|enrolments|suppress … [--site <slug>] [--json]",
576
+ usage: "frontpage mail save|people|settings|email|template|series|campaign|send|sends|enrolments|suppress … [--site <slug>] [--json]",
577
577
  topics: ["content"],
578
578
  examples: [
579
- "frontpage mail settings set --postal \"100 Main St\" --reply-to you@example.com",
579
+ 'frontpage mail settings set --postal "100 Main St" --reply-to you@example.com',
580
580
  "frontpage mail email create --file draft.json --name Hours",
581
581
  "frontpage mail send --file email.json --to ada@example.com --wait",
582
582
  "frontpage mail send template <id> --to ada@example.com --var first_name=Ada",
@@ -584,6 +584,11 @@ export const COMMANDS = [
584
584
  "frontpage mail suppress add --email bounce@example.com",
585
585
  ],
586
586
  flags: [
587
+ "save --file save.json: one retry-safe email/series save; existing records need expectedUpdatedAt. See MAIL.md.",
588
+ "people --q --source --sort newest|oldest|name --availability all|eligible|restricted --page --selected --selected-only.",
589
+ "settings set --file settings.json supports all eight fields and null resets; --header-align, --window-start, --window-end, --hourly-cap null|1..1000.",
590
+ "sends list --q --status --type campaign|series|confirmation|template|test|inline --page 1 --limit 20.",
591
+ "Series status active means On; paused/draft mean Off. Enrollment while Off waits at step 1, without sending. Existing people resume their next unsent step.",
587
592
  "campaign preview|audience|send|get. --id DRAFT_ID, --file audience.json ({audience:{groupIds|stageIds|personIds|filters}}). send requires --request-key; reuse it after a network error. Templates/series are for automation; campaigns have separate drafts. See PEOPLE.md.",
588
593
  "email list is unsent drafts only. Sent one-offs are mail sends list",
589
594
  "send --file is an inline one-off (no saved document)",
@@ -617,10 +622,10 @@ export const COMMANDS = [
617
622
  examples: [
618
623
  "frontpage seo get /",
619
624
  "frontpage seo get --path /updates",
620
- "frontpage seo set --path / --title \"Home\"",
625
+ 'frontpage seo set --path / --title "Home"',
621
626
  ],
622
627
  flags: [
623
- "get accepts positional path or --path (flag wins); unknown leftover flags fail closed"
628
+ "get accepts positional path or --path (flag wins); unknown leftover flags fail closed",
624
629
  ],
625
630
  },
626
631
  {
@@ -838,7 +843,7 @@ export const TOPICS = {
838
843
  # Store catalog + live storefront: https://frontpage.host/developers/store
839
844
  # Integer cents only. $49.99 is 4999. stock --add/--remove are signed delta.
840
845
  # Markup: data-fp-store-grid / data-fp-store-product / data-fp-cart. One item.astro, not one file per product.
841
- frontpage mail settings|email|template|series|send|sends|enrolments|suppress
846
+ frontpage mail save|people|settings|email|template|series|send|sends|enrolments|suppress
842
847
  frontpage seo get [/path|--path /path] | seo set --path /
843
848
  frontpage redirects list|set|rm --from /old --to /new
844
849
  frontpage articles list|content|create-section|create-article|set-draft|…
package/llms.txt CHANGED
@@ -34,6 +34,8 @@
34
34
 
35
35
  ## People groups, stages, rules, and imports
36
36
 
37
+ New owner-added and CSV-imported contacts default to allowed for marketing. Explicit unknown/not_allowed choices and saved refusals, unsubscribes and delivery restrictions still apply. Imports keep their accepted policy on retry. `mail people --json` returns a specific `reasonCode` for unavailable recipients.
38
+
37
39
  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
40
 
39
41
  Customer Inbox (Scale and internal sites): CUSTOMER-INBOX.md. Settings, widget, knowledge, human guidance/relay, lifecycle, export/download and recovery. The owner toolbar manages all site-wide Inbox operations through manage_customer_inbox, including conversations, files and recovery, but cannot edit/unlock locked knowledge. The support agent is restricted to its verified customer and cannot change shared knowledge from customer input. No borrowed human credentials.
@@ -47,3 +49,7 @@ Offerings own every standalone Pay now payment. Use `frontpage payments buttons
47
49
  Payments owns Stripe currency. Read Offering receiptVariables before creating a confirmation template; attach it through Payments. Forms use submitted input names. Full receipt contract: PAYMENTS.md.
48
50
 
49
51
  Customer Inbox: Frontpage website chat uses one settings toggle, no website edit/publish. get website shows public availability/managed addresses. websiteKnowledge.enabled controls default-on support website reads; no source or private-preview access. Source-ID toggles, attention filtering and held receipt pagination are supported. See CUSTOMER-INBOX.md.
52
+
53
+ ## Emails workspace workflows
54
+
55
+ See [MAIL.md](./MAIL.md) for the complete save contract, reusable people picker directory, saved campaign recipients, Off-series enrollment and resume timing, all sender settings/null resets, suppression rules, Sent queries and details, and retry-safe examples. `frontpage mail save --file save.json` saves template and pending series together; it never sends. `frontpage mail people` reads the same site-scoped directory as the workspace. Use `frontpage help mail --json` for current commands and flags. No new CLI binary, API namespace or migration command is required. Hosted guides and article help must be updated through the Frontpage CLI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frontpage-host",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Frontpage CLI — manage your site from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "AGENTS.md",
13
13
  "llms.txt",
14
14
  "PEOPLE.md",
15
+ "MAIL.md",
15
16
  "CUSTOMER-INBOX.md",
16
17
  "PAYMENTS.md"
17
18
  ],