@formigio/fazemos-cli 0.10.60 → 0.10.63

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.
@@ -0,0 +1,655 @@
1
+ /**
2
+ * F52 — Email Ingest Phase B: unified `ingest` CLI command group.
3
+ *
4
+ * Exports `registerIngestCommands(program)` which wires the following
5
+ * subcommand surface under `fazemos ingest`:
6
+ *
7
+ * ── Merged from F51 (formerly `email-ingest`, group renamed → `ingest`) ──────
8
+ *
9
+ * sender-map — ADD / LIST / REMOVE sender → project routing rules
10
+ * personas — LIST ingest personas (shared inbound addresses)
11
+ * holds — LIST / SHOW / RESOLVE / DROP held-ambiguous emails
12
+ *
13
+ * fazemos ingest sender-map add <email> --project <slug> [--note <text>]
14
+ * fazemos ingest sender-map list [--project <slug>]
15
+ * fazemos ingest sender-map remove <id>
16
+ * fazemos ingest personas list
17
+ * fazemos ingest holds list [--persona <local-part>]
18
+ * fazemos ingest holds show <id>
19
+ * fazemos ingest holds resolve <id> --project <slug>
20
+ * fazemos ingest holds drop <id> --reason <text>
21
+ *
22
+ * ── New in F52 ────────────────────────────────────────────────────────────────
23
+ *
24
+ * project — Per-project intake configuration (intake worksheet + status)
25
+ *
26
+ * fazemos ingest project set-intake [--project <slug>] --worksheet <ws_id>
27
+ * → PUT /api/email-ingest/projects/:projectId/intake {worksheet_id}
28
+ * Sets the intake worksheet destination for inbound email. Requires
29
+ * owner/admin. Resolves project slug → UUID via the F15 resolution chain.
30
+ *
31
+ * fazemos ingest project show [--project <slug>]
32
+ * → GET /api/email-ingest/projects/:projectId/intake
33
+ * Shows the current intake worksheet and known-sender count for the project.
34
+ *
35
+ * Note: the CLI group is renamed `ingest` (from F51's `email-ingest`). The
36
+ * underlying API route prefix `/api/email-ingest/…` is UNCHANGED — do NOT
37
+ * rename the API routes. Only the CLI verb changes.
38
+ *
39
+ * Auth: all commands require a valid Cognito session and org owner/admin role.
40
+ * Server enforces the role gate; the CLI surfaces 401/403 errors clearly.
41
+ * Agent identities (fzm_ / fzx_ API keys) are blocked server-side.
42
+ *
43
+ * Spec: F52-email-ingest-client-message-agent-triage-tech-spec.md §7, §10
44
+ * Merged: F51-email-ingest-mvp branch feature/F51-email-ingest-mvp@6a80015
45
+ */
46
+ import chalk from 'chalk';
47
+ import { api, ApiError, resolveProjectIdBySlug } from '../api.js';
48
+ // ── Shared helpers ─────────────────────────────────────────────────────────────
49
+ function formatDate(iso) {
50
+ if (!iso)
51
+ return '—';
52
+ return new Date(iso).toLocaleString();
53
+ }
54
+ /**
55
+ * Resolve a project slug to its UUID for org-scoped commands that take an
56
+ * explicit --project flag (sender-map, holds). Exits 1 with a clear message if
57
+ * the slug is unknown. Uses the standard F15 cache + refresh chain.
58
+ */
59
+ async function resolveProjectIdOrExit(slug) {
60
+ let projectId;
61
+ try {
62
+ projectId = await resolveProjectIdBySlug(slug);
63
+ }
64
+ catch (err) {
65
+ console.error(chalk.red(err?.message ?? String(err)));
66
+ process.exit(1);
67
+ }
68
+ if (!projectId) {
69
+ console.error(chalk.red(`Error: Unknown project: ${slug}`));
70
+ console.error(chalk.gray('Run: fazemos projects list'));
71
+ process.exit(1);
72
+ }
73
+ return projectId;
74
+ }
75
+ /**
76
+ * Resolve the active project ID for project-scoped intake commands.
77
+ *
78
+ * Applies the standard F15 resolution chain:
79
+ * --project <slug> override → resolveProjectIdBySlug (cache + refresh)
80
+ * active project from config → getActiveProjectId() (via resolveProjectIdBySlug(undefined))
81
+ *
82
+ * Exits 1 with the uniform "requirement missing: project" block (KD9) if
83
+ * neither a slug override nor an active project can be found.
84
+ */
85
+ async function requireProjectForIntake(slugOverride) {
86
+ let projectId;
87
+ try {
88
+ projectId = await resolveProjectIdBySlug(slugOverride);
89
+ }
90
+ catch (err) {
91
+ console.error(chalk.red(err?.message ?? String(err)));
92
+ process.exit(1);
93
+ }
94
+ if (!projectId) {
95
+ console.error(chalk.red('Error: requirement missing: project'));
96
+ console.error('');
97
+ console.error(chalk.gray('Set one with: fazemos projects switch <slug>'));
98
+ console.error(chalk.gray('Or pass: --project <slug>'));
99
+ process.exit(1);
100
+ }
101
+ return projectId;
102
+ }
103
+ // ── Persona resolution helper (for holds --persona filter) ────────────────────
104
+ /**
105
+ * Resolve a persona local_part (e.g. "kate") to its UUID.
106
+ * Fetches GET /api/email-ingest/personas and looks up by local_part.
107
+ * Exits 1 with a clear message if the persona is not found.
108
+ */
109
+ async function resolvePersonaIdOrExit(localPart) {
110
+ let data;
111
+ try {
112
+ data = await api('GET', '/api/email-ingest/personas', undefined, { noProjectHeader: true });
113
+ }
114
+ catch (err) {
115
+ console.error(chalk.red(err?.message ?? String(err)));
116
+ process.exit(1);
117
+ }
118
+ const persona = (data.personas ?? []).find((p) => p.local_part === localPart);
119
+ if (!persona) {
120
+ console.error(chalk.red(`Unknown persona: ${localPart}`));
121
+ console.error(chalk.gray('Run: fazemos ingest personas list'));
122
+ process.exit(1);
123
+ }
124
+ return persona.id;
125
+ }
126
+ // ── sender-map commands ────────────────────────────────────────────────────────
127
+ function registerSenderMapCommands(parent) {
128
+ const senderMap = parent
129
+ .command('sender-map')
130
+ .description('Manage sender → project routing rules for email ingest');
131
+ // ── sender-map add ──────────────────────────────────────────────────────────
132
+ senderMap
133
+ .command('add')
134
+ .description('Add a sender → project mapping. Emails from <email> are routed to the given project ' +
135
+ 'when the From header matches. Uses RFC 5322 header matching (not envelope-from).')
136
+ .argument('<email>', 'Sender email address to match against the RFC 5322 From header')
137
+ .requiredOption('--project <slug>', 'Target project slug (routing destination)')
138
+ .option('--note <text>', 'Optional note explaining why this mapping exists')
139
+ .action(async (email, opts) => {
140
+ try {
141
+ const projectId = await resolveProjectIdOrExit(opts.project);
142
+ const body = {
143
+ sender_email: email,
144
+ project_id: projectId,
145
+ };
146
+ if (opts.note !== undefined) {
147
+ body.note = opts.note;
148
+ }
149
+ const data = await api('POST', '/api/email-ingest/sender-map', body, { noProjectHeader: true });
150
+ console.log(chalk.green('Sender mapping created.'));
151
+ console.log(` ID: ${data.id}`);
152
+ console.log(` Sender: ${data.sender_email}`);
153
+ console.log(` Project ID: ${data.project_id}`);
154
+ if (data.note)
155
+ console.log(` Note: ${data.note}`);
156
+ console.log(` Created at: ${formatDate(data.created_at)}`);
157
+ }
158
+ catch (err) {
159
+ if (err instanceof ApiError) {
160
+ if (err.code === 'DUPLICATE_SENDER_MAPPING') {
161
+ console.error(chalk.red(`Error: A mapping for ${email} → that project already exists`));
162
+ }
163
+ else if (err.code === 'PROJECT_NOT_FOUND') {
164
+ console.error(chalk.red('Error: Project not found — verify the slug with: fazemos projects list'));
165
+ }
166
+ else if (err.code === 'MISSING_SENDER_EMAIL') {
167
+ console.error(chalk.red('Error: sender email is required'));
168
+ }
169
+ else if (err.code === 'MISSING_PROJECT_ID') {
170
+ console.error(chalk.red('Error: --project is required'));
171
+ }
172
+ else if (err.status === 403) {
173
+ console.error(chalk.red('Error: Only org owners and admins can manage email ingest settings'));
174
+ }
175
+ else if (err.status === 401) {
176
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
177
+ }
178
+ else {
179
+ console.error(chalk.red(err.message));
180
+ }
181
+ }
182
+ else {
183
+ console.error(chalk.red(err?.message ?? String(err)));
184
+ }
185
+ process.exit(1);
186
+ }
187
+ });
188
+ // ── sender-map list ─────────────────────────────────────────────────────────
189
+ senderMap
190
+ .command('list')
191
+ .description('List sender → project mappings. Optionally filter by project slug.')
192
+ .option('--project <slug>', 'Filter mappings by project slug')
193
+ .action(async (opts) => {
194
+ try {
195
+ let url = '/api/email-ingest/sender-map';
196
+ if (opts.project) {
197
+ const projectId = await resolveProjectIdOrExit(opts.project);
198
+ url += `?project_id=${encodeURIComponent(projectId)}`;
199
+ }
200
+ const data = await api('GET', url, undefined, { noProjectHeader: true });
201
+ const items = data.sender_mappings ?? [];
202
+ if (items.length === 0) {
203
+ console.log(chalk.yellow('No sender mappings'));
204
+ console.log(chalk.gray('Add one with: fazemos ingest sender-map add <email> --project <slug>'));
205
+ return;
206
+ }
207
+ const senderW = Math.max(6, ...items.map((m) => String(m.sender_email ?? '').length));
208
+ const projW = Math.max(10, ...items.map((m) => String(m.project_id ?? '').length));
209
+ const noteW = Math.max(4, ...items.map((m) => String(m.note ?? '').length));
210
+ const header = [
211
+ 'SENDER'.padEnd(senderW),
212
+ 'PROJECT ID'.padEnd(projW),
213
+ 'NOTE'.padEnd(noteW),
214
+ 'CREATED AT',
215
+ ].join(' ');
216
+ console.log(chalk.gray(header));
217
+ console.log(chalk.gray('─'.repeat(header.length)));
218
+ for (const m of items) {
219
+ console.log([
220
+ String(m.sender_email ?? '').padEnd(senderW),
221
+ String(m.project_id ?? '').padEnd(projW),
222
+ String(m.note ?? '').padEnd(noteW),
223
+ formatDate(m.created_at),
224
+ ].join(' '));
225
+ }
226
+ }
227
+ catch (err) {
228
+ if (err instanceof ApiError) {
229
+ if (err.status === 403) {
230
+ console.error(chalk.red('Error: Only org owners and admins can view email ingest settings'));
231
+ }
232
+ else if (err.status === 401) {
233
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
234
+ }
235
+ else {
236
+ console.error(chalk.red(err.message));
237
+ }
238
+ }
239
+ else {
240
+ console.error(chalk.red(err?.message ?? String(err)));
241
+ }
242
+ process.exit(1);
243
+ }
244
+ });
245
+ // ── sender-map remove ───────────────────────────────────────────────────────
246
+ senderMap
247
+ .command('remove')
248
+ .description('Remove a sender → project mapping by ID')
249
+ .argument('<id>', 'Mapping ID (UUID — from: fazemos ingest sender-map list)')
250
+ .action(async (id) => {
251
+ try {
252
+ await api('DELETE', `/api/email-ingest/sender-map/${encodeURIComponent(id)}`, undefined, { noProjectHeader: true });
253
+ console.log(chalk.green(`Sender mapping ${id} removed.`));
254
+ }
255
+ catch (err) {
256
+ if (err instanceof ApiError) {
257
+ if (err.code === 'SENDER_MAPPING_NOT_FOUND') {
258
+ console.error(chalk.red(`Error: Sender mapping not found: ${id}`));
259
+ }
260
+ else if (err.status === 403) {
261
+ console.error(chalk.red('Error: Only org owners and admins can manage email ingest settings'));
262
+ }
263
+ else if (err.status === 401) {
264
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
265
+ }
266
+ else {
267
+ console.error(chalk.red(err.message));
268
+ }
269
+ }
270
+ else {
271
+ console.error(chalk.red(err?.message ?? String(err)));
272
+ }
273
+ process.exit(1);
274
+ }
275
+ });
276
+ }
277
+ // ── personas commands ──────────────────────────────────────────────────────────
278
+ function registerPersonasCommands(parent) {
279
+ const personas = parent
280
+ .command('personas')
281
+ .description('View email ingest personas (shared inbound addresses)');
282
+ // ── personas list ───────────────────────────────────────────────────────────
283
+ personas
284
+ .command('list')
285
+ .description('List all email ingest personas. ' +
286
+ 'Returns [] in fresh environments until personas are seeded — see rollout step 6.')
287
+ .action(async () => {
288
+ try {
289
+ const data = await api('GET', '/api/email-ingest/personas', undefined, { noProjectHeader: true });
290
+ const items = data.personas ?? [];
291
+ if (items.length === 0) {
292
+ console.log(chalk.yellow('No personas configured'));
293
+ console.log(chalk.gray('Personas are seeded by an operator via the DB (rollout step 6).'));
294
+ return;
295
+ }
296
+ const localW = Math.max(10, ...items.map((p) => String(p.local_part ?? '').length));
297
+ const displayW = Math.max(12, ...items.map((p) => String(p.display_name ?? '').length));
298
+ const header = [
299
+ 'LOCAL PART'.padEnd(localW),
300
+ 'DISPLAY NAME'.padEnd(displayW),
301
+ 'ACTIVE',
302
+ ].join(' ');
303
+ console.log(chalk.gray(header));
304
+ console.log(chalk.gray('─'.repeat(header.length)));
305
+ for (const p of items) {
306
+ const active = p.active ? chalk.green('yes') : chalk.gray('no');
307
+ console.log([
308
+ String(p.local_part ?? '').padEnd(localW),
309
+ String(p.display_name ?? '').padEnd(displayW),
310
+ active,
311
+ ].join(' '));
312
+ }
313
+ }
314
+ catch (err) {
315
+ if (err instanceof ApiError) {
316
+ if (err.status === 403) {
317
+ console.error(chalk.red('Error: Only org owners and admins can view email ingest personas'));
318
+ }
319
+ else if (err.status === 401) {
320
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
321
+ }
322
+ else {
323
+ console.error(chalk.red(err.message));
324
+ }
325
+ }
326
+ else {
327
+ console.error(chalk.red(err?.message ?? String(err)));
328
+ }
329
+ process.exit(1);
330
+ }
331
+ });
332
+ }
333
+ // ── holds commands ─────────────────────────────────────────────────────────────
334
+ /**
335
+ * Print detailed information for a single hold record.
336
+ */
337
+ function printHold(h) {
338
+ console.log(chalk.cyan(`Hold: ${h.id}`));
339
+ console.log(` From: ${h.from_header}`);
340
+ console.log(` Subject: ${h.subject ?? chalk.gray('(no subject)')}`);
341
+ console.log(` Received: ${formatDate(h.received_at)}`);
342
+ console.log(` Persona ID: ${h.persona_id}`);
343
+ const candidates = h.candidate_projects ?? [];
344
+ if (candidates.length > 0) {
345
+ console.log(` Candidates: ${candidates.join(', ')}`);
346
+ }
347
+ }
348
+ function registerHoldsCommands(parent) {
349
+ const holds = parent
350
+ .command('holds')
351
+ .description('Manage held-ambiguous email ingest events');
352
+ // ── holds list ──────────────────────────────────────────────────────────────
353
+ holds
354
+ .command('list')
355
+ .description('List held-ambiguous emails awaiting operator action. ' +
356
+ 'Use --persona <local-part> to filter by ingest address (e.g. --persona kate).')
357
+ .option('--persona <local-part>', 'Filter by persona local part (e.g. kate)')
358
+ .action(async (opts) => {
359
+ try {
360
+ let url = '/api/email-ingest/holds';
361
+ if (opts.persona) {
362
+ const personaId = await resolvePersonaIdOrExit(opts.persona);
363
+ url += `?persona_id=${encodeURIComponent(personaId)}`;
364
+ }
365
+ const data = await api('GET', url, undefined, { noProjectHeader: true });
366
+ const items = data.holds ?? [];
367
+ if (items.length === 0) {
368
+ console.log(chalk.yellow('No held emails'));
369
+ if (opts.persona) {
370
+ console.log(chalk.gray(`No holds for persona: ${opts.persona}`));
371
+ }
372
+ return;
373
+ }
374
+ const fromW = Math.max(4, ...items.map((h) => String(h.from_header ?? '').length));
375
+ const subjW = Math.max(7, ...items.map((h) => String(h.subject ?? '').length));
376
+ const header = [
377
+ 'ID'.padEnd(36),
378
+ 'FROM'.padEnd(fromW),
379
+ 'SUBJECT'.padEnd(subjW),
380
+ 'RECEIVED AT',
381
+ ].join(' ');
382
+ console.log(chalk.gray(header));
383
+ console.log(chalk.gray('─'.repeat(header.length)));
384
+ for (const h of items) {
385
+ console.log([
386
+ String(h.id ?? '').padEnd(36),
387
+ String(h.from_header ?? '').padEnd(fromW),
388
+ String(h.subject ?? '').padEnd(subjW),
389
+ formatDate(h.received_at),
390
+ ].join(' '));
391
+ }
392
+ }
393
+ catch (err) {
394
+ if (err instanceof ApiError) {
395
+ if (err.status === 403) {
396
+ console.error(chalk.red('Error: Only org owners and admins can view email ingest holds'));
397
+ }
398
+ else if (err.status === 401) {
399
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
400
+ }
401
+ else {
402
+ console.error(chalk.red(err.message));
403
+ }
404
+ }
405
+ else {
406
+ console.error(chalk.red(err?.message ?? String(err)));
407
+ }
408
+ process.exit(1);
409
+ }
410
+ });
411
+ // ── holds show ──────────────────────────────────────────────────────────────
412
+ holds
413
+ .command('show')
414
+ .description('Show details of a held email by ID. ' +
415
+ 'Note: fetches all current holds and filters client-side — ' +
416
+ 'returns not-found if the hold has already been resolved or dropped.')
417
+ .argument('<id>', 'Hold ID (UUID — from: fazemos ingest holds list)')
418
+ .action(async (id) => {
419
+ try {
420
+ // No dedicated GET /holds/:id endpoint — fetch full list and filter.
421
+ const data = await api('GET', '/api/email-ingest/holds', undefined, { noProjectHeader: true });
422
+ const hold = (data.holds ?? []).find((h) => h.id === id);
423
+ if (!hold) {
424
+ console.error(chalk.red(`Hold not found: ${id}`));
425
+ console.error(chalk.gray('The hold may have been resolved or dropped already.'));
426
+ console.error(chalk.gray('Run: fazemos ingest holds list'));
427
+ process.exit(1);
428
+ }
429
+ printHold(hold);
430
+ }
431
+ catch (err) {
432
+ if (err instanceof ApiError) {
433
+ if (err.status === 403) {
434
+ console.error(chalk.red('Error: Only org owners and admins can view email ingest holds'));
435
+ }
436
+ else if (err.status === 401) {
437
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
438
+ }
439
+ else {
440
+ console.error(chalk.red(err.message));
441
+ }
442
+ }
443
+ else {
444
+ console.error(chalk.red(err?.message ?? String(err)));
445
+ }
446
+ process.exit(1);
447
+ }
448
+ });
449
+ // ── holds resolve ───────────────────────────────────────────────────────────
450
+ holds
451
+ .command('resolve')
452
+ .description('Resolve a held email by routing it to a project. ' +
453
+ 'Creates a worksheet and sends a retroactive persona-signed acknowledgement ' +
454
+ '(variant=resolved). Idempotent — safe to call twice for the same hold.')
455
+ .argument('<id>', 'Hold ID (UUID — from: fazemos ingest holds list)')
456
+ .requiredOption('--project <slug>', 'Project slug to route this email to')
457
+ .action(async (id, opts) => {
458
+ try {
459
+ const projectId = await resolveProjectIdOrExit(opts.project);
460
+ const data = await api('POST', `/api/email-ingest/holds/${encodeURIComponent(id)}/resolve`, { project_id: projectId }, { noProjectHeader: true });
461
+ console.log(chalk.green('Hold resolved.'));
462
+ console.log(` Worksheet ID: ${data.worksheet_id}`);
463
+ console.log(` Ack sent at: ${formatDate(data.ack_sent_at)}`);
464
+ }
465
+ catch (err) {
466
+ if (err instanceof ApiError) {
467
+ if (err.code === 'HOLD_NOT_FOUND') {
468
+ console.error(chalk.red(`Error: Hold not found: ${id}`));
469
+ }
470
+ else if (err.code === 'HOLD_NOT_RESOLVABLE') {
471
+ console.error(chalk.red(`Error: ${err.message}`));
472
+ }
473
+ else if (err.code === 'PROJECT_NOT_FOUND') {
474
+ console.error(chalk.red('Error: Project not found — verify the slug with: fazemos projects list'));
475
+ }
476
+ else if (err.code === 'MISSING_PROJECT_ID') {
477
+ console.error(chalk.red('Error: --project is required'));
478
+ }
479
+ else if (err.status === 403) {
480
+ console.error(chalk.red('Error: Only org owners and admins can resolve email ingest holds'));
481
+ }
482
+ else if (err.status === 401) {
483
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
484
+ }
485
+ else {
486
+ console.error(chalk.red(err.message));
487
+ }
488
+ }
489
+ else {
490
+ console.error(chalk.red(err?.message ?? String(err)));
491
+ }
492
+ process.exit(1);
493
+ }
494
+ });
495
+ // ── holds drop ──────────────────────────────────────────────────────────────
496
+ holds
497
+ .command('drop')
498
+ .description('Drop a held email (terminal action — no worksheet created, no ack sent). ' +
499
+ 'Provide a reason for the audit trail. Dropped state is final — cannot be reopened.')
500
+ .argument('<id>', 'Hold ID (UUID — from: fazemos ingest holds list)')
501
+ .requiredOption('--reason <text>', 'Reason for dropping (recorded in the audit trail)')
502
+ .action(async (id, opts) => {
503
+ try {
504
+ await api('POST', `/api/email-ingest/holds/${encodeURIComponent(id)}/drop`, { reason: opts.reason }, { noProjectHeader: true });
505
+ console.log(chalk.green(`Hold ${id} dropped.`));
506
+ console.log(chalk.gray('Dropped is a terminal state — this hold cannot be reopened.'));
507
+ }
508
+ catch (err) {
509
+ if (err instanceof ApiError) {
510
+ if (err.code === 'HOLD_NOT_FOUND') {
511
+ console.error(chalk.red(`Error: Hold not found: ${id}`));
512
+ }
513
+ else if (err.code === 'HOLD_NOT_DROPPABLE') {
514
+ console.error(chalk.red(`Error: ${err.message}`));
515
+ }
516
+ else if (err.code === 'MISSING_REASON') {
517
+ console.error(chalk.red('Error: --reason is required'));
518
+ }
519
+ else if (err.status === 403) {
520
+ console.error(chalk.red('Error: Only org owners and admins can drop email ingest holds'));
521
+ }
522
+ else if (err.status === 401) {
523
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
524
+ }
525
+ else {
526
+ console.error(chalk.red(err.message));
527
+ }
528
+ }
529
+ else {
530
+ console.error(chalk.red(err?.message ?? String(err)));
531
+ }
532
+ process.exit(1);
533
+ }
534
+ });
535
+ }
536
+ // ── project commands (F52-native) ─────────────────────────────────────────────
537
+ function registerProjectIntakeCommands(parent) {
538
+ const project = parent
539
+ .command('project')
540
+ .description('Per-project intake configuration — set and view the intake worksheet destination');
541
+ // ── project set-intake ──────────────────────────────────────────────────────
542
+ project
543
+ .command('set-intake')
544
+ .description('Set the intake worksheet destination for inbound email on this project. ' +
545
+ 'Emails routed to this project will create child worksheets under <ws_id>. ' +
546
+ 'Requires owner/admin.')
547
+ .option('--project <slug>', 'Project slug (defaults to the active project)')
548
+ .requiredOption('--worksheet <ws_id>', 'Worksheet ID (UUID) to use as the intake destination')
549
+ .action(async (opts) => {
550
+ try {
551
+ const projectId = await requireProjectForIntake(opts.project);
552
+ const data = await api('PUT', `/api/email-ingest/projects/${encodeURIComponent(projectId)}/intake`, { worksheet_id: opts.worksheet }, { noProjectHeader: true });
553
+ console.log(chalk.green('Intake worksheet set.'));
554
+ console.log(` Intake worksheet ID: ${data.intake_worksheet_id}`);
555
+ console.log(` Worksheet name: ${data.worksheet_name}`);
556
+ console.log(chalk.gray('Inbound email routed to this project will now create worksheets under that destination.'));
557
+ }
558
+ catch (err) {
559
+ if (err instanceof ApiError) {
560
+ if (err.code === 'MISSING_WORKSHEET_ID') {
561
+ console.error(chalk.red('Error: --worksheet is required'));
562
+ }
563
+ else if (err.code === 'WORKSHEET_WRONG_PROJECT') {
564
+ console.error(chalk.red('Error: Worksheet does not belong to this project'));
565
+ console.error(chalk.gray('Run: fazemos worksheets list to see worksheets for this project'));
566
+ }
567
+ else if (err.code === 'PROJECT_NOT_FOUND') {
568
+ console.error(chalk.red('Error: Project not found — verify the slug with: fazemos projects list'));
569
+ }
570
+ else if (err.status === 403) {
571
+ console.error(chalk.red('Error: Only org owners and admins can configure email ingest'));
572
+ }
573
+ else if (err.status === 401) {
574
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
575
+ }
576
+ else {
577
+ console.error(chalk.red(err.message));
578
+ }
579
+ }
580
+ else {
581
+ console.error(chalk.red(err?.message ?? String(err)));
582
+ }
583
+ process.exit(1);
584
+ }
585
+ });
586
+ // ── project show ────────────────────────────────────────────────────────────
587
+ project
588
+ .command('show')
589
+ .description('Show the intake configuration for this project: intake worksheet, ' +
590
+ 'known-sender count, and current status.')
591
+ .option('--project <slug>', 'Project slug (defaults to the active project)')
592
+ .action(async (opts) => {
593
+ try {
594
+ const projectId = await requireProjectForIntake(opts.project);
595
+ const data = await api('GET', `/api/email-ingest/projects/${encodeURIComponent(projectId)}/intake`, undefined, { noProjectHeader: true });
596
+ console.log(chalk.cyan('Email Ingest — Project Intake'));
597
+ if (data.intake_worksheet_id) {
598
+ console.log(` Intake worksheet ID: ${data.intake_worksheet_id}`);
599
+ console.log(` Worksheet name: ${data.worksheet_name ?? chalk.gray('(unknown)')}`);
600
+ }
601
+ else {
602
+ console.log(` Intake worksheet: ${chalk.yellow('not set')}`);
603
+ console.log(chalk.gray(' Set one with: fazemos ingest project set-intake --worksheet <ws_id>'));
604
+ console.log(chalk.gray(' Without an intake worksheet, inbound email is held (held-no-intake) until one is set.'));
605
+ }
606
+ console.log(` Known senders: ${data.known_sender_count ?? 0}`);
607
+ if ((data.known_sender_count ?? 0) === 0) {
608
+ console.log(chalk.gray(' Add senders with: fazemos ingest sender-map add <email> --project <slug>'));
609
+ }
610
+ }
611
+ catch (err) {
612
+ if (err instanceof ApiError) {
613
+ if (err.code === 'PROJECT_NOT_FOUND') {
614
+ console.error(chalk.red('Error: Project not found — verify the slug with: fazemos projects list'));
615
+ }
616
+ else if (err.status === 403) {
617
+ console.error(chalk.red('Error: Only org owners and admins can view email ingest configuration'));
618
+ }
619
+ else if (err.status === 401) {
620
+ console.error(chalk.red('Error: Not authenticated. Run: fazemos auth login'));
621
+ }
622
+ else {
623
+ console.error(chalk.red(err.message));
624
+ }
625
+ }
626
+ else {
627
+ console.error(chalk.red(err?.message ?? String(err)));
628
+ }
629
+ process.exit(1);
630
+ }
631
+ });
632
+ }
633
+ // ── Top-level registration ─────────────────────────────────────────────────────
634
+ /**
635
+ * Register the unified `ingest` command group under the root program.
636
+ *
637
+ * Sub-groups:
638
+ * sender-map (F51 merge-forward, renamed from email-ingest)
639
+ * personas (F51 merge-forward, renamed from email-ingest)
640
+ * holds (F51 merge-forward, renamed from email-ingest)
641
+ * project (F52-native: set-intake, show)
642
+ *
643
+ * Invoke from src/index.ts after the other registerXxxCommands() calls.
644
+ */
645
+ export function registerIngestCommands(program) {
646
+ const ingest = program
647
+ .command('ingest')
648
+ .description('Email ingest management — sender routing, personas, held-mail resolution, ' +
649
+ 'and per-project intake configuration (F51/F52)');
650
+ registerSenderMapCommands(ingest);
651
+ registerPersonasCommands(ingest);
652
+ registerHoldsCommands(ingest);
653
+ registerProjectIntakeCommands(ingest);
654
+ }
655
+ //# sourceMappingURL=ingest.js.map