frontpage-host 0.1.26 → 0.1.28

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/PEOPLE.md CHANGED
@@ -102,11 +102,12 @@ frontpage people groups create --file repeat-buyers.json --site YOUR_SITE --json
102
102
  frontpage people membership override --person PERSON_ID --group GROUP_ID --override include --site YOUR_SITE --json
103
103
  frontpage people membership override --person PERSON_ID --group GROUP_ID --override exclude --site YOUR_SITE --json
104
104
  frontpage people membership override --person PERSON_ID --group GROUP_ID --override auto --site YOUR_SITE --json
105
- frontpage people membership hold --person PERSON_ID --group GROUP_ID --hold true --site YOUR_SITE --json
106
105
  frontpage people membership hold --person PERSON_ID --group GROUP_ID --hold false --site YOUR_SITE --json
107
106
  ```
108
107
 
109
- `include` keeps one person in an automatic group; `exclude` keeps them out. `auto` clears the exception. A hold pauses automatic changes and that group’s attached email for this person. It does not prevent an owner from making an explicit change.
108
+ `include` keeps one person in an automatic group; `exclude` keeps them out. `auto` clears the exception and applies that group’s conditions immediately when the needed facts are available. It can add or remove membership and run the group’s normal rules. It does not change other groups or email permission. An earlier saved pause still prevents automatic changes.
109
+
110
+ **Earlier pauses.** New `--hold true` requests fail unless the person already has that pause; an existing pause remains unchanged. Use `--hold false` to resume a saved pause. This keeps membership, stage and exception, restarts the time-rule clock, and skips earlier pending work for this person/group. It fails with HTTP 409 while a group rule is running; try again after it finishes. Archived groups must be restored first. No saved pause is cleared automatically. Failed runs remain available for an explicit review and retry; resuming does not retry them.
110
111
 
111
112
  **Preserve existing records.**
112
113
 
@@ -337,18 +338,78 @@ Copy the attachment’s returned rule ID before enabling it. Pass `--enabled fal
337
338
 
338
339
  See the [Email guide](/developers/mail) for content blocks, template variables, series step delays, sender settings, and email limits.
339
340
 
340
- ## Email permission is independent of groups
341
+ ## Marketing email permission
342
+
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. |
341
353
 
342
- Group membership never grants email permission. A person who opts out stays in their groups and stages, while automated marketing emails and campaigns stop. Every delivery checks permission again, so an unsubscribe after audience selection still stops delivery.
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.
343
355
 
344
356
  ```
345
- frontpage people person permission --person PERSON_ID --allowed false --site YOUR_SITE --json
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
346
363
  frontpage people membership get --person PERSON_ID --site YOUR_SITE --json
347
- # Only record permission when the person has actually given it:
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
348
366
  frontpage people person permission --person PERSON_ID --allowed true --site YOUR_SITE --json
349
367
  ```
350
368
 
351
- `membership.get` includes email eligibility and the reason a contact cannot receive mail. Missing email, hidden state, missing permission, saved unsubscribe, and suppression can block delivery. An owner can record a new permission decision, but cannot reverse a mailing-list unsubscribe or suppression. CSV values cannot reverse an existing opt-out, including a recorded false decision. Do not add a permission check to every group or stage; delivery already enforces it.
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.
352
413
 
353
414
  ## Import a customer list from CSV
354
415
 
@@ -363,7 +424,7 @@ Alex Rivera,alex@example.com,,
363
424
  Phone customer,,+12025550199,
364
425
  ```
365
426
 
366
- Map exact header names in `mapping.json`. The permission column is optional; omit it when permission is unknown. `yes`/`true` records existing permission and `no`/`false` records no permission.
427
+ 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.
367
428
 
368
429
  ```
369
430
  {
@@ -375,7 +436,8 @@ Map exact header names in `mapping.json`. The permission column is optional; omi
375
436
  },
376
437
  "groupIds": [
377
438
  "GROUP_ID"
378
- ]
439
+ ],
440
+ "emailPermission": "unknown"
379
441
  }
380
442
  ```
381
443
 
@@ -528,12 +590,12 @@ The agent can create and edit saved templates and series with the existing Email
528
590
  | stages.reorder | groupId, stageIds | Save every active stage ID in order, once each. |
529
591
  | stages.archive / stages.restore | id, groupId | Keep assignments and history while archiving or restoring. |
530
592
  | membership.get | personId | Memberships and email eligibility. |
531
- | membership.add / membership.remove | personId, groupId; reset? on add | Join or leave a manual group. |
593
+ | membership.add / membership.remove | personId, groupId; reset? on add | Join or leave a group; a conditions group stores an include or exclude exception. |
532
594
  | membership.move | personId, fromGroupId, groupId, reset? | Leave one manual group and join another. |
533
595
  | membership.stage | personId, groupId, stageId | Set a stage; null clears it without leaving the group. |
534
596
  | membership.advance | personId, groupId | Move to the next nonterminal stage. |
535
597
  | membership.override | personId, groupId, override | Use include, exclude, or null for automatic membership. |
536
- | membership.hold | personId, groupId, hold | Pause or resume automatic changes for this person/group. |
598
+ | membership.hold | personId, groupId, hold | Resume an earlier saved pause; creating a new pause is no longer supported. |
537
599
  | rules.list | archived? | List rules; archived:true includes archived rules. |
538
600
  | rules.get | id | Read the saved rule and version. |
539
601
  | rules.create | name, trigger, conditions?, actions, enabled? | Create a rule. Pass enabled:false to review it first. |
@@ -544,7 +606,7 @@ The agent can create and edit saved templates and series with the existing Email
544
606
  | rules.runs | ruleId?, cursor?, limit? | Inspect saved outcomes and failed actions. |
545
607
  | rules.retry | runId or ruleId | Retry eligible failed work; completed action receipts stay saved. |
546
608
  | attachments.create | groupId, stageId?, emailKind, emailId, enabled?, name?, conditions? | Create an ordinary group/stage entry rule for a template or series. |
547
- | person.permission | personId, allowed | Record an email-permission decision; mailing-list unsubscribe and suppression still win. |
609
+ | 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. |
548
610
  | audience.preview | audience | Preview matched and eligible People for a campaign. |
549
611
  | import.preview | csv, mapping?, headersOnly? | Validate a CSV and match existing contacts; sends nothing. |
550
612
  | import.start | csv, mapping?, groupIds?, requestKey | Start a saved import. |
@@ -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);
package/dist/registry.js CHANGED
@@ -466,7 +466,7 @@ export const COMMANDS = [
466
466
  "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
467
  "list/export: --group GROUP_ID --stage STAGE_ID. See package PEOPLE.md for full examples and every JSON shape.",
468
468
  "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)",
469
+ "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
470
  "tag: --add <label> | --remove <slug>",
471
471
  "export writes UTF-8 CSV (same filters as list). Optional --out <file>",
472
472
  "Hide is not forget. Unique email/phone stay; later ingest un-hides",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frontpage-host",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Frontpage CLI — manage your site from the terminal",
5
5
  "type": "module",
6
6
  "bin": {