quoxcli 0.4.0 → 0.6.0
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/CHANGELOG.md +110 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +58 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +7 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +427 -25
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +69 -144
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +617 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +52 -12
- package/lib/commands/plan.js +487 -52
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/volt.js +6 -1
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +880 -0
- package/lib/commands/wazuhIntake.js +358 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +42 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +120 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +30 -5
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +189 -34
- package/lib/quoxon/app.js +1277 -188
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +72 -12
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +8 -2
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +8 -1
- package/lib/quoxon/commands.js +42 -12
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +118 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +55 -8
- package/lib/quoxon/extensions.js +400 -38
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +7 -2
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +27 -6
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +68 -16
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +88 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +48 -11
- package/lib/quoxon/slash/broadcastRun.js +24 -7
- package/lib/quoxon/slash/cliLeaves.json +98 -1
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +63 -1
- package/lib/quoxon/slash/paletteController.js +15 -5
- package/lib/quoxon/slash/restart.js +9 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +24 -3
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +42 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +61 -3
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
package/lib/commands/mail.js
CHANGED
|
@@ -11,6 +11,9 @@ import { readFileSync, statSync } from 'fs';
|
|
|
11
11
|
import { formatOutput, formatDetail, printSuccess } from '../core/output.js';
|
|
12
12
|
import { parseJSON, resolveOrgId } from './helpers.js';
|
|
13
13
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
14
|
+
import { announce, announceOnFail } from '../core/announce.js';
|
|
15
|
+
import { confirmInteractive } from '../core/confirm.js';
|
|
16
|
+
import { QuoxUserError } from '../core/errors.js';
|
|
14
17
|
|
|
15
18
|
// --- minimal CSV parsing (quoted fields + escaped quotes, no deps) ----------
|
|
16
19
|
function splitCsvLine(line) {
|
|
@@ -46,8 +49,80 @@ function parseCsvFile(path) {
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
const VALID_ZONES = ['focus', 'fyi', 'auto', 'screened'];
|
|
49
|
-
|
|
52
|
+
// The server accepts file|block|draft|rezone|folder_create|rule (api.js PROPOSAL_KINDS).
|
|
53
|
+
// 'rule' is left out on purpose: it has no executor, so approving one only records the
|
|
54
|
+
// decision and nothing happens to the mailbox (api.js hResolveProposal). Offering it
|
|
55
|
+
// would let a user file an approval that can never do what it says.
|
|
56
|
+
const VALID_KINDS = ['file', 'block', 'draft', 'rezone', 'folder_create'];
|
|
57
|
+
const PROPOSE_PAYLOADS = `Payload per --kind (a JSON object):
|
|
58
|
+
block {"scope":"sender"|"domain","target":"<address or domain>"}
|
|
59
|
+
scope sender: target is the sender address (fromAddr is accepted too);
|
|
60
|
+
scope domain: target is the domain, e.g. "spam.test" (domain is accepted too).
|
|
61
|
+
Approving it runs the same block as \`quox mail block\`: stored mail is
|
|
62
|
+
screened and moved to Junk. \`quox mail block <addr|@domain> --propose\`
|
|
63
|
+
builds this payload for you.
|
|
64
|
+
file {"messageIds":["<id>",...],"folder":"<folder name, id or IMAP path>"}
|
|
65
|
+
rezone {"messageIds":["<id>",...],"toZone":"focus"|"fyi"|"auto"|"screened"}
|
|
66
|
+
folder_create {"name":"<folder name>"}
|
|
67
|
+
draft {"messageId":"<id to reply to>","body":"<text>"} (or "to" instead of messageId)
|
|
68
|
+
Kind "rule" is not offered: the server has no executor for it yet, so approving one would change nothing.`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse a block/unblock target: "@domain" is a domain, "a@b" a sender address.
|
|
72
|
+
* --scope domain on an address blocks the address's domain.
|
|
73
|
+
* -> { scope, target, fromAddr?, domain? } (all lower-cased).
|
|
74
|
+
*/
|
|
75
|
+
function parseBlockTarget(raw, scopeOpt) {
|
|
76
|
+
const t = String(raw || '').trim().toLowerCase();
|
|
77
|
+
if (scopeOpt && !['sender', 'domain'].includes(scopeOpt)) {
|
|
78
|
+
throw new QuoxUserError(`invalid --scope "${scopeOpt}" (expected sender or domain)`, { exitCode: 2 });
|
|
79
|
+
}
|
|
80
|
+
if (t.startsWith('@')) {
|
|
81
|
+
const domain = t.slice(1);
|
|
82
|
+
if (!domain || domain.includes('@')) throw new QuoxUserError(`"${raw}" is not a domain. Use @example.com.`, { exitCode: 2 });
|
|
83
|
+
if (scopeOpt === 'sender') throw new QuoxUserError(`--scope sender needs a sender address, not ${raw}. Drop --scope (an @domain is a domain block) or give the address.`, { exitCode: 2 });
|
|
84
|
+
return { scope: 'domain', target: domain, domain };
|
|
85
|
+
}
|
|
86
|
+
const at = t.indexOf('@');
|
|
87
|
+
if (at > 0 && at < t.length - 1 && t.indexOf('@', at + 1) === -1) {
|
|
88
|
+
if (scopeOpt === 'domain') {
|
|
89
|
+
const domain = t.slice(at + 1);
|
|
90
|
+
return { scope: 'domain', target: domain, fromAddr: t, domain };
|
|
91
|
+
}
|
|
92
|
+
return { scope: 'sender', target: t, fromAddr: t };
|
|
93
|
+
}
|
|
94
|
+
throw new QuoxUserError(`"${raw}" is not a sender address or @domain. Give an address (tanya@example.com) or a domain (@example.com). To find an address by name: quox mail list --account <id>`, { exitCode: 2 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The block proposal payload the server executor reads (api.js executeBlockProposal). */
|
|
98
|
+
function blockProposalPayload(t) {
|
|
99
|
+
return t.scope === 'domain'
|
|
100
|
+
? { scope: 'domain', target: t.target, domain: t.domain }
|
|
101
|
+
: { scope: 'sender', target: t.target, fromAddr: t.fromAddr };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Validate a block payload before it reaches the server (the server also checks). */
|
|
105
|
+
function checkBlockPayload(payload) {
|
|
106
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
107
|
+
if (!['sender', 'domain'].includes(p.scope)) {
|
|
108
|
+
throw new QuoxUserError('block payload needs "scope": "sender" or "domain". Example: {"scope":"sender","target":"tanya@example.com"}', { exitCode: 2 });
|
|
109
|
+
}
|
|
110
|
+
const target = p.target || (p.scope === 'domain' ? (p.domain || (p.fromAddr ? String(p.fromAddr).split('@')[1] : '')) : p.fromAddr);
|
|
111
|
+
if (!target || !String(target).trim()) {
|
|
112
|
+
throw new QuoxUserError(`block payload needs "target" (the ${p.scope === 'domain' ? 'domain' : 'sender address'}). Example: {"scope":"${p.scope}","target":"${p.scope === 'domain' ? 'example.com' : 'tanya@example.com'}"}`, { exitCode: 2 });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const JUNK_NAME = /\b(spam|junk|bulk)\b/i;
|
|
117
|
+
|
|
118
|
+
/** --yes (local or root -y) or an interactive y/N. Throws with no terminal. */
|
|
119
|
+
async function confirmWrite(program, options, message) {
|
|
120
|
+
if (options.yes || (program.opts && program.opts().yes)) return true;
|
|
121
|
+
return confirmInteractive(message, { flag: '--yes', purpose: 'confirm' });
|
|
122
|
+
}
|
|
50
123
|
const VALID_DECISIONS = ['approve', 'deny'];
|
|
124
|
+
// quox#1518: past tense comes from a map, never verb + 'd' ("denyd").
|
|
125
|
+
const DECISION_PAST_TENSE = { approve: 'approved', deny: 'denied', reject: 'rejected' };
|
|
51
126
|
|
|
52
127
|
// Small CLI-local copy of src/config/emailProviders.js's EMAIL_PROVIDERS.
|
|
53
128
|
// The cli/ package can't import from src/, so the host/port defaults for
|
|
@@ -67,6 +142,21 @@ function printJson(data) {
|
|
|
67
142
|
console.log(JSON.stringify(data, null, 2));
|
|
68
143
|
}
|
|
69
144
|
|
|
145
|
+
// quox#1504: a failure is printed as a failure (stderr, non-zero exit), never
|
|
146
|
+
// dressed up as an empty success.
|
|
147
|
+
function printFailure(message) {
|
|
148
|
+
console.error(message);
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pinHint(accountId, error) {
|
|
153
|
+
return /^TLS: /.test(error || '')
|
|
154
|
+
? `If this server's certificate is one you trust, an org admin can pin it: quox mail account pin ${accountId} --sha256 <fingerprint>`
|
|
155
|
+
: null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
159
|
+
|
|
70
160
|
export function registerMailCommands(program, ctx) {
|
|
71
161
|
const { client, auth } = ctx;
|
|
72
162
|
|
|
@@ -85,9 +175,9 @@ export function registerMailCommands(program, ctx) {
|
|
|
85
175
|
const data = await client.get(ep.path, { service: ep.service });
|
|
86
176
|
if (isJsonOutput(program)) return printJson(data);
|
|
87
177
|
formatOutput({
|
|
88
|
-
columns: ['id', 'emailAddress', 'displayName', 'pollEnabled', '
|
|
89
|
-
headers: ['ID', 'Email', 'Name', 'Polling', '
|
|
90
|
-
rows: data.accounts || [],
|
|
178
|
+
columns: ['id', 'emailAddress', 'displayName', 'pollEnabled', 'tlsPin', 'lastError'],
|
|
179
|
+
headers: ['ID', 'Email', 'Name', 'Polling', 'TLS pin', 'Last error'],
|
|
180
|
+
rows: (data.accounts || []).map((a) => ({ ...a, tlsPin: a.tlsPinSha256 ? 'sha256 pinned' : '', lastError: a.lastError || '' })),
|
|
91
181
|
}, { format: program.opts().output });
|
|
92
182
|
});
|
|
93
183
|
|
|
@@ -288,31 +378,123 @@ export function registerMailCommands(program, ctx) {
|
|
|
288
378
|
|
|
289
379
|
mail
|
|
290
380
|
.command('sync')
|
|
291
|
-
.description('Sync a mail account now (fetch new messages, run triage)')
|
|
381
|
+
.description('Sync a mail account now (fetch new messages, run triage). Waits for the result and shows the real cause if the mailbox cannot be reached (TLS, login, network).')
|
|
292
382
|
.argument('<accountId>', 'account ID')
|
|
293
|
-
.
|
|
383
|
+
.option('--wait <seconds>', 'how long to wait for a background sync to finish (0 = do not wait)', '120')
|
|
384
|
+
.action(async (accountId, options) => {
|
|
294
385
|
auth.requireAuth();
|
|
295
386
|
const ep = resolveEndpoint('inboxq.sync');
|
|
296
|
-
// A first full-mailbox pull
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// "timed out"). Give sync a generous 4-minute ceiling (nginx allows 300s).
|
|
387
|
+
// A first full-mailbox pull can be slow; the collector usually runs it
|
|
388
|
+
// detached and returns {started:true} at once. Keep a generous ceiling
|
|
389
|
+
// for the inline shape (nginx allows 300s).
|
|
300
390
|
const data = await client.post(ep.path, { service: ep.service, body: { accountId }, timeout: 240000 });
|
|
391
|
+
|
|
392
|
+
// Detached run: poll sync-status until it finishes, then report the
|
|
393
|
+
// OUTCOME. quox#1504: this used to print "No folders reported" while the
|
|
394
|
+
// background run failed TLS and nobody saw why.
|
|
395
|
+
if (data && (data.started || data.status === 'already_syncing')) {
|
|
396
|
+
const sep = resolveEndpoint('inboxq.syncStatus');
|
|
397
|
+
const deadline = Date.now() + Math.max(0, Number(options.wait) || 0) * 1000;
|
|
398
|
+
let st = await client.get(sep.path, { service: sep.service, query: { accountId } });
|
|
399
|
+
while (st && st.syncing && Date.now() < deadline) {
|
|
400
|
+
await sleep(2000);
|
|
401
|
+
st = await client.get(sep.path, { service: sep.service, query: { accountId } });
|
|
402
|
+
}
|
|
403
|
+
if (isJsonOutput(program)) return printJson({ ...data, status: st });
|
|
404
|
+
if (st && st.lastError) {
|
|
405
|
+
printFailure(`Sync of ${accountId} failed: ${st.lastError}`);
|
|
406
|
+
for (const f of st.folderErrors || []) printFailure(` ${f.folder}: ${f.error} (failures in a row: ${f.consecutiveFailures}${f.retired ? ', retired' : ''})`);
|
|
407
|
+
const hint = pinHint(accountId, st.lastError);
|
|
408
|
+
if (hint) console.error(hint);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (st && st.syncing) {
|
|
412
|
+
printSuccess(`Sync of ${accountId} is still running (${st.stored ?? 0} messages stored so far). Check: quox mail sync-status ${accountId}`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
printSuccess(`Synced ${accountId}. ${st?.stored ?? 0} messages stored.`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
301
419
|
if (isJsonOutput(program)) return printJson(data);
|
|
302
420
|
const summary = data.summary || {};
|
|
303
421
|
const folders = summary.folders || [];
|
|
422
|
+
const failed = folders.filter((f) => f.failed);
|
|
304
423
|
if (folders.length === 0) {
|
|
305
|
-
|
|
424
|
+
if (data.lastError) { printFailure(`Sync of ${accountId} failed: ${data.lastError}`); return; }
|
|
425
|
+
printSuccess(`Synced ${accountId}. No folders were eligible this pass (all in backoff or retired); see quox mail sync-status ${accountId}.`);
|
|
306
426
|
return;
|
|
307
427
|
}
|
|
308
428
|
formatOutput({
|
|
309
|
-
columns: ['folder', 'new', '
|
|
310
|
-
headers: ['Folder', 'New', '
|
|
311
|
-
rows: folders,
|
|
429
|
+
columns: ['folder', 'new', 'error'],
|
|
430
|
+
headers: ['Folder', 'New', 'Error'],
|
|
431
|
+
rows: folders.map((f) => ({ ...f, new: f.new ?? '', error: f.error || f.skipped || '' })),
|
|
312
432
|
}, { format: program.opts().output });
|
|
433
|
+
if (failed.length) {
|
|
434
|
+
printFailure(`Sync of ${accountId}: ${failed.length} of ${folders.length} folder(s) failed: ${failed[0].error}`);
|
|
435
|
+
const hint = pinHint(accountId, failed[0].error);
|
|
436
|
+
if (hint) console.error(hint);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
313
439
|
printSuccess(`Synced ${accountId}. LLM calls: ${summary.llmCalls ?? 0}`);
|
|
314
440
|
});
|
|
315
441
|
|
|
442
|
+
mail
|
|
443
|
+
.command('sync-status')
|
|
444
|
+
.description('Show whether an account is syncing, how many messages are stored, and the last connect/TLS/login error if any')
|
|
445
|
+
.argument('<accountId>', 'account ID')
|
|
446
|
+
.action(async (accountId) => {
|
|
447
|
+
auth.requireAuth();
|
|
448
|
+
const sep = resolveEndpoint('inboxq.syncStatus');
|
|
449
|
+
const st = await client.get(sep.path, { service: sep.service, query: { accountId } });
|
|
450
|
+
if (isJsonOutput(program)) return printJson(st);
|
|
451
|
+
formatDetail({
|
|
452
|
+
syncing: Boolean(st.syncing),
|
|
453
|
+
stored: st.stored ?? 0,
|
|
454
|
+
folders: st.folders ? `${st.folders.backfilled}/${st.folders.total} fully imported` : '',
|
|
455
|
+
tlsPin: st.tlsPinSha256 || 'none (default certificate verification)',
|
|
456
|
+
lastError: st.lastError || 'none',
|
|
457
|
+
lastErrorAt: st.lastErrorAt || '',
|
|
458
|
+
}, { format: program.opts().output });
|
|
459
|
+
if ((st.folderErrors || []).length) {
|
|
460
|
+
formatOutput({
|
|
461
|
+
columns: ['folder', 'error', 'consecutiveFailures', 'retired', 'nextAttemptAt'],
|
|
462
|
+
headers: ['Folder', 'Error', 'Failures', 'Retired', 'Next attempt'],
|
|
463
|
+
rows: st.folderErrors,
|
|
464
|
+
}, { format: program.opts().output });
|
|
465
|
+
}
|
|
466
|
+
const hint = pinHint(accountId, st.lastError);
|
|
467
|
+
if (hint) console.error(hint);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// ── account (per-account settings) ───────────────────────────────────
|
|
471
|
+
|
|
472
|
+
const account = mail
|
|
473
|
+
.command('account')
|
|
474
|
+
.description('Per-account settings for a connected mailbox');
|
|
475
|
+
|
|
476
|
+
account
|
|
477
|
+
.command('pin')
|
|
478
|
+
.description('Pin the TLS certificate (SHA-256 fingerprint) an account trusts, for a mail server with a self-signed or private-CA certificate. Only that exact certificate is accepted; anything else still fails. Org admin only. Get the fingerprint with: openssl s_client -connect <host>:<port> </dev/null | openssl x509 -noout -fingerprint -sha256')
|
|
479
|
+
.argument('<accountId>', 'account ID')
|
|
480
|
+
.option('--sha256 <fingerprint>', 'SHA-256 certificate fingerprint (64 hex characters, colons optional; the openssl output line is accepted)')
|
|
481
|
+
.option('--clear', 'remove the pin (back to default certificate verification)')
|
|
482
|
+
.action(async (accountId, options) => {
|
|
483
|
+
auth.requireAuth();
|
|
484
|
+
if (Boolean(options.sha256) === Boolean(options.clear)) {
|
|
485
|
+
printFailure('Give exactly one of --sha256 <fingerprint> or --clear.');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const ep = resolveEndpoint('inboxq.accounts.tlsPin', { accountId });
|
|
489
|
+
const body = options.clear ? { clear: true } : { sha256: options.sha256 };
|
|
490
|
+
const data = await client.put(ep.path, { service: ep.service, body });
|
|
491
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
492
|
+
const pin = data?.account?.tlsPinSha256;
|
|
493
|
+
printSuccess(pin
|
|
494
|
+
? `Pinned ${accountId} to TLS certificate sha256 ${pin}. Run quox mail sync ${accountId} to retry now.`
|
|
495
|
+
: `Removed the TLS pin from ${accountId}; it uses default certificate verification again.`);
|
|
496
|
+
});
|
|
497
|
+
|
|
316
498
|
// ── messages ─────────────────────────────────────────────────────────
|
|
317
499
|
|
|
318
500
|
mail
|
|
@@ -408,20 +590,52 @@ export function registerMailCommands(program, ctx) {
|
|
|
408
590
|
|
|
409
591
|
watch
|
|
410
592
|
.command('add')
|
|
411
|
-
.description('Add a watch')
|
|
593
|
+
.description('Add a watch (awaited-mail alert). Give --match, or at least one of --from/--subject/--text/--zone.')
|
|
412
594
|
.requiredOption('--label <label>', 'watch label')
|
|
413
|
-
.
|
|
414
|
-
.option('--
|
|
595
|
+
.option('--match <json>', 'match rule as a JSON object (advanced; alternative to the --from/--subject/--text/--zone flags)')
|
|
596
|
+
.option('--from <addr>', 'match: sender address/name contains this')
|
|
597
|
+
.option('--subject <text>', 'match: subject contains this')
|
|
598
|
+
.option('--text <keyword>', 'match: message text contains this')
|
|
599
|
+
.option('--zone <zone>', 'match: only mail triaged into this zone (focus|fyi|auto|screened)')
|
|
600
|
+
.option('--notify <csv>', 'alert channels, comma list of hitl,lucid,telegram,discord,call,sms', 'hitl')
|
|
601
|
+
.option('--account <id>', 'restrict the watch to one account (omit to watch all your mailboxes)')
|
|
415
602
|
.action(async (options) => {
|
|
416
603
|
auth.requireAuth();
|
|
417
|
-
|
|
418
|
-
|
|
604
|
+
let match;
|
|
605
|
+
if (options.match) {
|
|
606
|
+
match = parseJSON(options.match, '--match');
|
|
607
|
+
} else {
|
|
608
|
+
match = {};
|
|
609
|
+
if (options.from) match.from = options.from;
|
|
610
|
+
if (options.subject) match.subject = options.subject;
|
|
611
|
+
if (options.text) match.text = options.text;
|
|
612
|
+
if (options.zone) match.zone = options.zone;
|
|
613
|
+
}
|
|
614
|
+
if (!match || typeof match !== 'object' || Object.keys(match).length === 0) {
|
|
615
|
+
throw new Error('Give --match, or at least one of --from, --subject, --text, --zone, or the watch would match nothing in particular.');
|
|
616
|
+
}
|
|
617
|
+
const notify = String(options.notify).split(',').map((s) => s.trim()).filter(Boolean);
|
|
618
|
+
const body = { label: options.label, match, notify };
|
|
419
619
|
if (options.account) body.accountId = options.account;
|
|
420
620
|
const ep = resolveEndpoint('inboxq.watches.create');
|
|
421
621
|
const data = await client.post(ep.path, { service: ep.service, body });
|
|
422
622
|
if (isJsonOutput(program)) return printJson(data);
|
|
423
623
|
const created = data.watch || data;
|
|
424
|
-
printSuccess(`Added watch "${options.label}" (${created.id})
|
|
624
|
+
printSuccess(`Added watch "${options.label}" (${created.id}); alerts via ${notify.join(', ')}.`);
|
|
625
|
+
const ext = notify.filter((c) => ['telegram', 'discord', 'call', 'sms'].includes(c));
|
|
626
|
+
if (ext.length) console.log(`Note: ${ext.join(', ')} need a destination configured, or they report unconfigured; hitl and lucid work out of the box.`);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
watch
|
|
630
|
+
.command('rm')
|
|
631
|
+
.description('Delete a watch')
|
|
632
|
+
.argument('<watchId>', 'watch ID')
|
|
633
|
+
.action(async (watchId) => {
|
|
634
|
+
auth.requireAuth();
|
|
635
|
+
const ep = resolveEndpoint('inboxq.watches.delete', { watchId });
|
|
636
|
+
const data = await client.delete(ep.path, { service: ep.service });
|
|
637
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
638
|
+
printSuccess(`Watch ${watchId} deleted.`);
|
|
425
639
|
});
|
|
426
640
|
|
|
427
641
|
watch
|
|
@@ -438,20 +652,208 @@ export function registerMailCommands(program, ctx) {
|
|
|
438
652
|
printSuccess(`Watch ${watchId} is now ${active ? 'active' : 'inactive'}.`);
|
|
439
653
|
});
|
|
440
654
|
|
|
655
|
+
// ── senders: block / unblock / blocked / junk / trust ──────────────────
|
|
656
|
+
// EMAIL-Q CLI audit (2026-11-09): the SAME routes the dashboard calls
|
|
657
|
+
// (src/components/inboxq/lib/api.js). Blocking is the human-authoritative
|
|
658
|
+
// instant path (POST /senders/block), exactly as the "Block sender" button;
|
|
659
|
+
// --propose files a governed block proposal instead.
|
|
660
|
+
|
|
661
|
+
mail
|
|
662
|
+
.command('block')
|
|
663
|
+
.description('Block a sender address or @domain on one account (stored mail screened and moved to Junk). --dry-run counts matches first; --propose files an approval instead')
|
|
664
|
+
.addHelpText('after', `
|
|
665
|
+
Same action as the dashboard "Block sender" button (POST /senders/block). Future mail from it is
|
|
666
|
+
screened while Quox syncs the account; no rule is set on the mail server itself.
|
|
667
|
+
Examples:
|
|
668
|
+
quox mail block tanya@example.com --account <id> --dry-run
|
|
669
|
+
quox mail block tanya@example.com --account <id> --yes
|
|
670
|
+
quox mail block @example.com --account <id> --propose
|
|
671
|
+
Proposal payload (--propose): {"scope":"sender"|"domain","target":"<address or domain>"}
|
|
672
|
+
`)
|
|
673
|
+
.argument('<target>', 'sender address (tanya@example.com) or @domain (@example.com)')
|
|
674
|
+
.requiredOption('--account <id>', 'account ID (quox mail accounts)')
|
|
675
|
+
.option('--scope <scope>', 'sender or domain (default: sender for an address, domain for @domain)')
|
|
676
|
+
.option('--dry-run', 'count the stored messages that match and what would move; change nothing')
|
|
677
|
+
.option('--propose', 'file a block proposal for approval instead of blocking now (payload {scope, target, fromAddr|domain})')
|
|
678
|
+
.option('--yes', 'confirm the block (refused without it when there is no terminal)')
|
|
679
|
+
.action(announceOnFail('mail', (target) => `block ${target}`, async (rawTarget, options) => {
|
|
680
|
+
auth.requireAuth();
|
|
681
|
+
if (options.dryRun && options.propose) {
|
|
682
|
+
throw new QuoxUserError('Use --dry-run or --propose, not both. Run the dry run first, then --propose.', { exitCode: 2 });
|
|
683
|
+
}
|
|
684
|
+
const t = parseBlockTarget(rawTarget, options.scope);
|
|
685
|
+
const accountId = options.account;
|
|
686
|
+
|
|
687
|
+
if (options.dryRun) {
|
|
688
|
+
const ep = resolveEndpoint('inboxq.messages.search');
|
|
689
|
+
const query = { accountId, limit: 200 };
|
|
690
|
+
if (t.scope === 'domain') query.fromDomain = t.domain; else query.from = t.fromAddr;
|
|
691
|
+
const data = await client.get(ep.path, { service: ep.service, query });
|
|
692
|
+
const msgs = Array.isArray(data?.messages) ? data.messages : [];
|
|
693
|
+
// The block executor matches the sender EXACTLY (domain: address ends with @domain);
|
|
694
|
+
// the search is a substring match, so count only what the block would really touch.
|
|
695
|
+
const hit = (m) => {
|
|
696
|
+
const f = String(m.fromAddr || '').trim().toLowerCase();
|
|
697
|
+
return t.scope === 'domain' ? f.endsWith(`@${t.domain}`) : f === t.fromAddr;
|
|
698
|
+
};
|
|
699
|
+
const matched = msgs.filter(hit);
|
|
700
|
+
const alreadyInJunk = matched.filter((m) => JUNK_NAME.test(String(m.folder || ''))).length;
|
|
701
|
+
const capped = Number(data?.total || 0) > msgs.length;
|
|
702
|
+
const result = { dryRun: true, accountId, scope: t.scope, target: t.target, matched: matched.length, alreadyInJunk, wouldMove: matched.length - alreadyInJunk, capped };
|
|
703
|
+
if (isJsonOutput(program)) return printJson(result);
|
|
704
|
+
const atLeast = capped ? 'at least ' : '';
|
|
705
|
+
console.log(`Dry run: blocking ${t.target} (${t.scope}) on ${accountId} would screen ${atLeast}${matched.length} stored message(s) and move ${atLeast}${result.wouldMove} to Junk (${alreadyInJunk} already there).`);
|
|
706
|
+
if (capped) console.log('The search returns at most 200 messages, so the real count can be higher.');
|
|
707
|
+
console.log('Nothing changed. Run it without --dry-run to block, or with --propose to file an approval.');
|
|
708
|
+
announce('mail', `block ${t.target} dry run`, { result: `${atLeast}${matched.length} match, nothing changed`, status: 'ok' });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (options.propose) {
|
|
713
|
+
const ep = resolveEndpoint('inboxq.proposals.create');
|
|
714
|
+
const body = { accountId, kind: 'block', payload: blockProposalPayload(t), createdVia: 'cli' };
|
|
715
|
+
const data = await client.post(ep.path, { service: ep.service, body });
|
|
716
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
717
|
+
const proposal = data.proposal || data;
|
|
718
|
+
printSuccess(`Proposed blocking ${t.target} (${t.scope}) on ${accountId}: proposal ${proposal.id}. Nothing changes until it is approved.`);
|
|
719
|
+
console.log(`Approve: quox mail resolve ${proposal.id} --decision approve`);
|
|
720
|
+
announce('mail', `propose block ${t.target}`, { result: `proposal ${proposal.id}`, status: 'ok' });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const go = await confirmWrite(program, options, `Block ${t.target} (${t.scope}) on ${accountId}? Its stored mail is screened and moved to Junk.`);
|
|
725
|
+
if (!go) {
|
|
726
|
+
console.log('Not blocked. Nothing changed.');
|
|
727
|
+
announce('mail', `block ${t.target}`, { result: 'cancelled, nothing changed', status: 'warn' });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const ep = resolveEndpoint('inboxq.senders.block');
|
|
731
|
+
const body = t.scope === 'domain'
|
|
732
|
+
? { ...(t.fromAddr ? { fromAddr: t.fromAddr } : {}), accountId, scope: 'domain', domain: t.domain }
|
|
733
|
+
: { fromAddr: t.fromAddr, accountId, scope: 'sender' };
|
|
734
|
+
const data = await client.post(ep.path, { service: ep.service, body });
|
|
735
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
736
|
+
const screened = data?.screened ?? 0;
|
|
737
|
+
const moved = data?.junkMoved ?? 0;
|
|
738
|
+
printSuccess(`Blocked ${data?.target || t.target} (${data?.scope || t.scope}) on ${accountId}: ${screened} stored message(s) screened, ${moved} moved to ${data?.junkFolder || 'Junk'}.`);
|
|
739
|
+
if (data?.junkFailed) {
|
|
740
|
+
console.error(`The move to Junk failed for ${Math.max(0, (data.junkMatched ?? 0) - moved)} message(s); they are screened in Quox but still in their folder on the mail server. Retry: quox mail sync ${accountId}, then block again.`);
|
|
741
|
+
}
|
|
742
|
+
if (!data?.providerRule || data.providerRule === 'none') {
|
|
743
|
+
console.log('Future mail from it is screened while Quox syncs this account; no rule was set on the mail server itself.');
|
|
744
|
+
}
|
|
745
|
+
announce('mail', `block ${t.target}`, { result: `${screened} screened, ${moved} to Junk`, status: data?.junkFailed ? 'warn' : 'ok' });
|
|
746
|
+
}));
|
|
747
|
+
|
|
748
|
+
mail
|
|
749
|
+
.command('unblock')
|
|
750
|
+
.description('Lift a sender or domain block on one account (future mail is no longer screened for it). Mail already in Junk stays there; use quox mail trust <address> to move a sender\'s mail back. Example: quox mail unblock @example.com --account <id> --yes')
|
|
751
|
+
.argument('<target>', 'sender address or @domain')
|
|
752
|
+
.requiredOption('--account <id>', 'account ID')
|
|
753
|
+
.option('--scope <scope>', 'sender or domain (default: sender for an address, domain for @domain)')
|
|
754
|
+
.option('--yes', 'confirm (refused without it when there is no terminal)')
|
|
755
|
+
.action(announceOnFail('mail', (target) => `unblock ${target}`, async (rawTarget, options) => {
|
|
756
|
+
auth.requireAuth();
|
|
757
|
+
const t = parseBlockTarget(rawTarget, options.scope);
|
|
758
|
+
const go = await confirmWrite(program, options, `Unblock ${t.target} (${t.scope}) on ${options.account}?`);
|
|
759
|
+
if (!go) {
|
|
760
|
+
console.log('Not unblocked. Nothing changed.');
|
|
761
|
+
announce('mail', `unblock ${t.target}`, { result: 'cancelled, nothing changed', status: 'warn' });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const ep = resolveEndpoint('inboxq.senders.unblock');
|
|
765
|
+
const data = await client.post(ep.path, { service: ep.service, body: { target: t.target, type: t.scope, accountId: options.account } });
|
|
766
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
767
|
+
printSuccess(`Unblocked ${t.target} (${t.scope}) on ${options.account}.`);
|
|
768
|
+
announce('mail', `unblock ${t.target}`, { result: t.scope, status: 'ok' });
|
|
769
|
+
}));
|
|
770
|
+
|
|
771
|
+
mail
|
|
772
|
+
.command('blocked')
|
|
773
|
+
.description('List the blocked senders and domains on one account')
|
|
774
|
+
.requiredOption('--account <id>', 'account ID')
|
|
775
|
+
.action(async (options) => {
|
|
776
|
+
auth.requireAuth();
|
|
777
|
+
const ep = resolveEndpoint('inboxq.senders.blocked');
|
|
778
|
+
const data = await client.get(ep.path, { service: ep.service, query: { accountId: options.account } });
|
|
779
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
780
|
+
const rows = data?.blocked || [];
|
|
781
|
+
if (!rows.length) { console.log(`Nothing is blocked on ${options.account}.`); return; }
|
|
782
|
+
formatOutput({
|
|
783
|
+
columns: ['target', 'type', 'blockedAt'],
|
|
784
|
+
headers: ['Blocked', 'Type', 'Since'],
|
|
785
|
+
rows,
|
|
786
|
+
}, { format: program.opts().output });
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
mail
|
|
790
|
+
.command('junk')
|
|
791
|
+
.description('Confirm messages are junk: each sender is blocked and the messages move to the account\'s Junk folder on the mail server. Example: quox mail junk <messageId> <messageId> --account <id> --yes')
|
|
792
|
+
.argument('<messageIds...>', 'message IDs (quox mail list)')
|
|
793
|
+
.requiredOption('--account <id>', 'account ID')
|
|
794
|
+
.option('--yes', 'confirm (refused without it when there is no terminal)')
|
|
795
|
+
.action(announceOnFail('mail', (ids) => `junk ${ids.length} message(s)`, async (messageIds, options) => {
|
|
796
|
+
auth.requireAuth();
|
|
797
|
+
const go = await confirmWrite(program, options, `Mark ${messageIds.length} message(s) as junk on ${options.account}? Their senders are blocked and the messages move to Junk.`);
|
|
798
|
+
if (!go) {
|
|
799
|
+
console.log('Nothing marked as junk.');
|
|
800
|
+
announce('mail', `junk ${messageIds.length} message(s)`, { result: 'cancelled, nothing changed', status: 'warn' });
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const ep = resolveEndpoint('inboxq.messages.confirmJunk');
|
|
804
|
+
const data = await client.post(ep.path, { service: ep.service, body: { messageIds, accountId: options.account } });
|
|
805
|
+
if (isJsonOutput(program)) {
|
|
806
|
+
if ((data?.failed || []).length) process.exitCode = 1;
|
|
807
|
+
return printJson(data);
|
|
808
|
+
}
|
|
809
|
+
const failed = data?.failed || [];
|
|
810
|
+
printSuccess(`Moved ${data?.moved ?? 0} message(s) to Junk and blocked ${data?.blocked ?? 0} sender(s) on ${options.account}.`);
|
|
811
|
+
if (failed.length) printFailure(`${failed.length} message(s) were not moved: ${failed.join(', ')} (not found on this account, or the mail server refused the move).`);
|
|
812
|
+
announce('mail', `junk ${messageIds.length} message(s)`, { result: `${data?.moved ?? 0} to Junk${failed.length ? `, ${failed.length} failed` : ''}`, status: failed.length ? 'fail' : 'ok' });
|
|
813
|
+
}));
|
|
814
|
+
|
|
815
|
+
mail
|
|
816
|
+
.command('trust')
|
|
817
|
+
.description('Trust a sender (not junk): lifts their block, restores their stored mail to its zone, and moves it out of Junk back to INBOX. Example: quox mail trust tanya@example.com --account <id> --yes')
|
|
818
|
+
.argument('<address>', 'sender address')
|
|
819
|
+
.requiredOption('--account <id>', 'account ID')
|
|
820
|
+
.option('--yes', 'confirm (refused without it when there is no terminal)')
|
|
821
|
+
.action(announceOnFail('mail', (a) => `trust ${a}`, async (rawAddress, options) => {
|
|
822
|
+
auth.requireAuth();
|
|
823
|
+
const t = parseBlockTarget(rawAddress);
|
|
824
|
+
if (t.scope !== 'sender') throw new QuoxUserError('trust takes one sender address, not a domain.', { exitCode: 2 });
|
|
825
|
+
const go = await confirmWrite(program, options, `Trust ${t.fromAddr} on ${options.account}? Their mail moves out of Junk back to INBOX.`);
|
|
826
|
+
if (!go) {
|
|
827
|
+
console.log('Not trusted. Nothing changed.');
|
|
828
|
+
announce('mail', `trust ${t.fromAddr}`, { result: 'cancelled, nothing changed', status: 'warn' });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const ep = resolveEndpoint('inboxq.senders.trust');
|
|
832
|
+
const data = await client.post(ep.path, { service: ep.service, body: { fromAddr: t.fromAddr, accountId: options.account } });
|
|
833
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
834
|
+
const back = data?.movedBack;
|
|
835
|
+
printSuccess(`Trusted ${t.fromAddr} on ${options.account}: ${data?.restored ?? 0} stored message(s) restored${back !== undefined ? `, ${back} moved back to INBOX` : ''}.`);
|
|
836
|
+
if (data?.movedBackFailed) console.error('Some of their mail could not be moved out of Junk on the mail server; it is restored in Quox only.');
|
|
837
|
+
announce('mail', `trust ${t.fromAddr}`, { result: `${data?.restored ?? 0} restored`, status: data?.movedBackFailed ? 'warn' : 'ok' });
|
|
838
|
+
}));
|
|
839
|
+
|
|
441
840
|
// ── proposals ────────────────────────────────────────────────────────
|
|
442
841
|
|
|
443
842
|
mail
|
|
444
843
|
.command('propose')
|
|
445
|
-
.description('
|
|
844
|
+
.description('Propose a change for approval (file, block, draft, rezone, folder_create). Block payload: {"scope":"sender"|"domain","target":"<address or domain>"}')
|
|
446
845
|
.requiredOption('--account <id>', 'account ID')
|
|
447
|
-
.requiredOption('--kind <kind>',
|
|
448
|
-
.requiredOption('--payload <json>', 'proposal payload as a JSON object')
|
|
846
|
+
.requiredOption('--kind <kind>', `proposal kind: ${VALID_KINDS.join(', ')} (not rule: no executor yet)`)
|
|
847
|
+
.requiredOption('--payload <json>', 'proposal payload as a JSON object (shape per kind below)')
|
|
848
|
+
.addHelpText('after', `\nNothing changes until a human approves it: quox mail resolve <id> --decision approve\n\n${PROPOSE_PAYLOADS}\n`)
|
|
449
849
|
.action(async (options) => {
|
|
450
850
|
auth.requireAuth();
|
|
451
851
|
if (!VALID_KINDS.includes(options.kind)) {
|
|
452
|
-
|
|
852
|
+
const why = options.kind === 'rule' ? ' Kind "rule" has no executor on the server yet, so approving it would change nothing.' : '';
|
|
853
|
+
throw new Error(`invalid --kind "${options.kind}" (expected one of ${VALID_KINDS.join(', ')}).${why}`);
|
|
453
854
|
}
|
|
454
855
|
const payload = parseJSON(options.payload, '--payload');
|
|
856
|
+
if (options.kind === 'block') checkBlockPayload(payload);
|
|
455
857
|
const body = { accountId: options.account, kind: options.kind, payload };
|
|
456
858
|
const ep = resolveEndpoint('inboxq.proposals.create');
|
|
457
859
|
const data = await client.post(ep.path, { service: ep.service, body });
|
|
@@ -493,7 +895,7 @@ export function registerMailCommands(program, ctx) {
|
|
|
493
895
|
const ep = resolveEndpoint('inboxq.proposals.resolve', { proposalId });
|
|
494
896
|
const data = await client.post(ep.path, { service: ep.service, body: { decision: options.decision } });
|
|
495
897
|
if (isJsonOutput(program)) return printJson(data);
|
|
496
|
-
printSuccess(`Proposal ${proposalId} ${options.decision}
|
|
898
|
+
printSuccess(`Proposal ${proposalId} ${DECISION_PAST_TENSE[options.decision] || options.decision}.`);
|
|
497
899
|
});
|
|
498
900
|
|
|
499
901
|
// ── moves (AQ-9E U2: reversible moves) ─────────────────────────────────
|