@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.
- package/dist/api.d.ts +23 -0
- package/dist/api.js +21 -0
- package/dist/api.js.map +1 -1
- package/dist/commands/ingest.d.ts +58 -0
- package/dist/commands/ingest.js +655 -0
- package/dist/commands/ingest.js.map +1 -0
- package/dist/index.js +182 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|