@primitivedotdev/sdk 0.20.0 → 0.22.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/dist/api/generated/index.js +1 -1
- package/dist/api/generated/sdk.gen.js +30 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +40 -5
- package/dist/{api-DNF21MDo.js → api-BjzvA2Fy.js} +63 -6
- package/dist/{index-C6ObsYjq.d.ts → index-QTYQpSFt.d.ts} +215 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/oclif/api-command.js +98 -14
- package/dist/oclif/auth.js +65 -10
- package/dist/oclif/commands/emails-latest.js +23 -12
- package/dist/oclif/commands/emails-poll.js +121 -0
- package/dist/oclif/commands/emails-wait.js +171 -0
- package/dist/oclif/commands/emails-watch.js +165 -0
- package/dist/oclif/commands/functions-deploy.js +15 -6
- package/dist/oclif/commands/functions-redeploy.js +15 -6
- package/dist/oclif/commands/functions-set-secret.js +213 -0
- package/dist/oclif/commands/login.js +18 -14
- package/dist/oclif/commands/logout.js +9 -8
- package/dist/oclif/commands/send.js +21 -7
- package/dist/oclif/commands/whoami.js +15 -6
- package/dist/oclif/fish-completion.js +1 -1
- package/dist/oclif/index.js +14 -0
- package/dist/openapi/openapi.generated.js +397 -2
- package/dist/openapi/operations.generated.js +305 -1
- package/oclif.manifest.json +1413 -285
- package/package.json +2 -2
package/dist/oclif/auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { DEFAULT_API_BASE_URL_1, DEFAULT_API_BASE_URL_2, } from "../api/index.js";
|
|
5
5
|
const CREDENTIALS_FILE = "credentials.json";
|
|
6
6
|
const CREDENTIALS_LOCK_DIR = "credentials.lock";
|
|
7
7
|
const CREDENTIALS_LOCK_STALE_MS = 30 * 60 * 1000;
|
|
@@ -16,10 +16,34 @@ function requireString(value, key) {
|
|
|
16
16
|
}
|
|
17
17
|
return raw;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Sentinel returned by parseCredentials when the on-disk credentials
|
|
21
|
+
* were written by a pre-dual-host CLI version (i.e. they have
|
|
22
|
+
* `base_url` instead of `api_base_url_1`). The caller treats this as
|
|
23
|
+
* "no saved credentials" after auto-cleaning the stale file. Defined
|
|
24
|
+
* as a class-tagged error so loadCliCredentials can distinguish it
|
|
25
|
+
* from a genuine malformed-credentials error.
|
|
26
|
+
*/
|
|
27
|
+
class StaleCredentialFormatError extends Error {
|
|
28
|
+
constructor() {
|
|
29
|
+
super("stale_credential_format");
|
|
30
|
+
this.name = "StaleCredentialFormatError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
19
33
|
function parseCredentials(raw) {
|
|
20
34
|
if (!isRecord(raw)) {
|
|
21
35
|
throw new Error(`Stored Primitive CLI credentials are malformed: expected a JSON object. ${MALFORMED_CREDENTIALS_HINT}`);
|
|
22
36
|
}
|
|
37
|
+
// Stored credentials from an older CLI version used the field name
|
|
38
|
+
// `base_url`; the dual-host rename moved this to `api_base_url_1`.
|
|
39
|
+
// Detect the old shape specifically so loadCliCredentials can wipe
|
|
40
|
+
// the stale file and emit a clear "you've been logged out" notice
|
|
41
|
+
// instead of every command hard-failing with a generic "malformed"
|
|
42
|
+
// error that doesn't surface the actual fix (re-login).
|
|
43
|
+
if (typeof raw.api_base_url_1 !== "string" &&
|
|
44
|
+
typeof raw.base_url === "string") {
|
|
45
|
+
throw new StaleCredentialFormatError();
|
|
46
|
+
}
|
|
23
47
|
const orgName = raw.org_name;
|
|
24
48
|
if (orgName !== null && typeof orgName !== "string") {
|
|
25
49
|
throw new Error(`Stored Primitive CLI credentials are malformed: org_name must be a string or null. ${MALFORMED_CREDENTIALS_HINT}`);
|
|
@@ -30,19 +54,25 @@ function parseCredentials(raw) {
|
|
|
30
54
|
key_prefix: requireString(raw, "key_prefix"),
|
|
31
55
|
org_id: requireString(raw, "org_id"),
|
|
32
56
|
org_name: orgName,
|
|
33
|
-
|
|
57
|
+
api_base_url_1: requireString(raw, "api_base_url_1"),
|
|
34
58
|
created_at: requireString(raw, "created_at"),
|
|
35
59
|
};
|
|
36
60
|
}
|
|
37
61
|
export function credentialsPath(configDir) {
|
|
38
62
|
return join(configDir, CREDENTIALS_FILE);
|
|
39
63
|
}
|
|
40
|
-
|
|
41
|
-
const trimmed =
|
|
64
|
+
function normalize(url, fallback) {
|
|
65
|
+
const trimmed = url?.trim();
|
|
42
66
|
if (!trimmed)
|
|
43
|
-
return
|
|
67
|
+
return fallback;
|
|
44
68
|
return trimmed.replace(/\/+$/, "");
|
|
45
69
|
}
|
|
70
|
+
export function normalizeApiBaseUrl1(url) {
|
|
71
|
+
return normalize(url, DEFAULT_API_BASE_URL_1);
|
|
72
|
+
}
|
|
73
|
+
export function normalizeApiBaseUrl2(url) {
|
|
74
|
+
return normalize(url, DEFAULT_API_BASE_URL_2);
|
|
75
|
+
}
|
|
46
76
|
export function loadCliCredentials(configDir) {
|
|
47
77
|
const path = credentialsPath(configDir);
|
|
48
78
|
let contents;
|
|
@@ -62,6 +92,24 @@ export function loadCliCredentials(configDir) {
|
|
|
62
92
|
return parseCredentials(JSON.parse(contents));
|
|
63
93
|
}
|
|
64
94
|
catch (error) {
|
|
95
|
+
if (error instanceof StaleCredentialFormatError) {
|
|
96
|
+
// Saved credentials were written by a pre-dual-host CLI version.
|
|
97
|
+
// The format is incompatible (base_url vs api_base_url_1) and
|
|
98
|
+
// cannot be recovered. Clear the file so the caller sees "no
|
|
99
|
+
// saved credentials" and emit a one-shot notice telling the
|
|
100
|
+
// user they need to log back in. Idempotent: once the file is
|
|
101
|
+
// gone, this branch never fires again.
|
|
102
|
+
try {
|
|
103
|
+
rmSync(path, { force: true });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Best-effort cleanup; if the unlink fails (permissions,
|
|
107
|
+
// racing process), the next CLI invocation will hit this
|
|
108
|
+
// path again and try once more.
|
|
109
|
+
}
|
|
110
|
+
process.stderr.write("You've been logged out: your saved Primitive CLI credentials were created by an older CLI version and are no longer compatible. Run `primitive login` to re-authenticate.\n");
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
65
113
|
if (error instanceof SyntaxError) {
|
|
66
114
|
throw new Error("Stored Primitive CLI credentials are not valid JSON. Run `primitive logout` and then `primitive login`.");
|
|
67
115
|
}
|
|
@@ -140,10 +188,15 @@ export function acquireCliCredentialsLock(configDir, options = {}) {
|
|
|
140
188
|
}
|
|
141
189
|
export function resolveCliAuth(params) {
|
|
142
190
|
const apiKey = params.apiKey?.trim();
|
|
191
|
+
// Host 2 (api_base_url_2) is never stored; either set by env/flag or
|
|
192
|
+
// falls back to the production default. The login flow only deals
|
|
193
|
+
// with host 1.
|
|
194
|
+
const apiBaseUrl2 = normalizeApiBaseUrl2(params.apiBaseUrl2);
|
|
143
195
|
if (apiKey) {
|
|
144
196
|
return {
|
|
145
197
|
apiKey,
|
|
146
|
-
|
|
198
|
+
apiBaseUrl1: normalizeApiBaseUrl1(params.apiBaseUrl1),
|
|
199
|
+
apiBaseUrl2,
|
|
147
200
|
credentials: null,
|
|
148
201
|
source: "flag-or-env",
|
|
149
202
|
};
|
|
@@ -152,16 +205,18 @@ export function resolveCliAuth(params) {
|
|
|
152
205
|
if (credentials) {
|
|
153
206
|
return {
|
|
154
207
|
apiKey: credentials.api_key,
|
|
155
|
-
|
|
156
|
-
?
|
|
157
|
-
: credentials.
|
|
208
|
+
apiBaseUrl1: params.apiBaseUrl1
|
|
209
|
+
? normalizeApiBaseUrl1(params.apiBaseUrl1)
|
|
210
|
+
: credentials.api_base_url_1,
|
|
211
|
+
apiBaseUrl2,
|
|
158
212
|
credentials,
|
|
159
213
|
source: "stored",
|
|
160
214
|
};
|
|
161
215
|
}
|
|
162
216
|
return {
|
|
163
217
|
apiKey: undefined,
|
|
164
|
-
|
|
218
|
+
apiBaseUrl1: normalizeApiBaseUrl1(params.apiBaseUrl1),
|
|
219
|
+
apiBaseUrl2,
|
|
165
220
|
credentials: null,
|
|
166
221
|
source: "none",
|
|
167
222
|
};
|
|
@@ -80,6 +80,9 @@ export function formatRow(email, idWidth) {
|
|
|
80
80
|
const subjectCol = truncate(subject, SUBJECT_DISPLAY_WIDTH);
|
|
81
81
|
return `${id} ${received} ${from} ${to} ${subjectCol}`;
|
|
82
82
|
}
|
|
83
|
+
export function formatHeader(idWidth) {
|
|
84
|
+
return `${"ID".padEnd(idWidth)} ${"RECEIVED (UTC)".padEnd(RECEIVED_DISPLAY_WIDTH)} ${"FROM".padEnd(ADDRESS_DISPLAY_WIDTH)} ${"TO".padEnd(ADDRESS_DISPLAY_WIDTH)} SUBJECT`;
|
|
85
|
+
}
|
|
83
86
|
class EmailsLatestCommand extends Command {
|
|
84
87
|
static description = `Print the N most recent inbound emails as a one-line-per-row text table. Designed for quick triage and visual scanning. For programmatic access, use \`primitive emails:list-emails\` (full JSON envelope, cursor pagination, filters) or pass \`--json\` here for the same raw shape without pagination/filters.
|
|
85
88
|
|
|
@@ -88,19 +91,25 @@ class EmailsLatestCommand extends Command {
|
|
|
88
91
|
Output streams: the column header line is written to STDERR so the row data on STDOUT stays grep/awk-friendly. \`--json\` writes everything (including the envelope) to STDOUT and is equivalent to running \`emails:list-emails --limit N\` for the same N.`;
|
|
89
92
|
static summary = "Show the most recent inbound emails as a compact table";
|
|
90
93
|
static examples = [
|
|
91
|
-
"<%= config.bin %> emails
|
|
92
|
-
"<%= config.bin %> emails
|
|
93
|
-
"<%= config.bin %> emails
|
|
94
|
-
"<%= config.bin %> emails
|
|
94
|
+
"<%= config.bin %> emails latest",
|
|
95
|
+
"<%= config.bin %> emails latest --limit 25",
|
|
96
|
+
"<%= config.bin %> emails latest | head -1 | awk '{print $1}' # full UUID since piped",
|
|
97
|
+
"<%= config.bin %> emails latest --json | jq '.data[0].id'",
|
|
95
98
|
];
|
|
96
99
|
static flags = {
|
|
97
100
|
"api-key": Flags.string({
|
|
98
101
|
description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
|
|
99
102
|
env: "PRIMITIVE_API_KEY",
|
|
100
103
|
}),
|
|
101
|
-
"base-url": Flags.string({
|
|
102
|
-
description: "API base URL
|
|
103
|
-
env: "
|
|
104
|
+
"api-base-url-1": Flags.string({
|
|
105
|
+
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
106
|
+
env: "PRIMITIVE_API_BASE_URL_1",
|
|
107
|
+
hidden: true,
|
|
108
|
+
}),
|
|
109
|
+
"api-base-url-2": Flags.string({
|
|
110
|
+
description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
|
|
111
|
+
env: "PRIMITIVE_API_BASE_URL_2",
|
|
112
|
+
hidden: true,
|
|
104
113
|
}),
|
|
105
114
|
limit: Flags.integer({
|
|
106
115
|
description: `Number of rows to print (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT}).`,
|
|
@@ -121,15 +130,18 @@ class EmailsLatestCommand extends Command {
|
|
|
121
130
|
async run() {
|
|
122
131
|
const { flags } = await this.parse(EmailsLatestCommand);
|
|
123
132
|
await runWithTiming(flags.time, async () => {
|
|
124
|
-
const baseUrlOverridden = flags["base-url"] !== undefined
|
|
133
|
+
const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
|
|
134
|
+
flags["api-base-url-2"] !== undefined;
|
|
125
135
|
const auth = resolveCliAuth({
|
|
126
136
|
apiKey: flags["api-key"],
|
|
127
|
-
|
|
137
|
+
apiBaseUrl1: flags["api-base-url-1"],
|
|
138
|
+
apiBaseUrl2: flags["api-base-url-2"],
|
|
128
139
|
configDir: this.config.configDir,
|
|
129
140
|
});
|
|
130
141
|
const apiClient = new PrimitiveApiClient({
|
|
131
142
|
apiKey: auth.apiKey,
|
|
132
|
-
|
|
143
|
+
apiBaseUrl1: auth.apiBaseUrl1,
|
|
144
|
+
apiBaseUrl2: auth.apiBaseUrl2,
|
|
133
145
|
});
|
|
134
146
|
const result = await listEmails({
|
|
135
147
|
client: apiClient.client,
|
|
@@ -163,8 +175,7 @@ class EmailsLatestCommand extends Command {
|
|
|
163
175
|
}
|
|
164
176
|
const idWidth = pickIdWidth(Boolean(process.stdout.isTTY));
|
|
165
177
|
// Header on stderr so the table itself stays grep-friendly.
|
|
166
|
-
|
|
167
|
-
process.stderr.write(`${header}\n`);
|
|
178
|
+
process.stderr.write(`${formatHeader(idWidth)}\n`);
|
|
168
179
|
for (const row of rows) {
|
|
169
180
|
this.log(formatRow(row, idWidth));
|
|
170
181
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { searchEmails } from "../../api/generated/sdk.gen.js";
|
|
2
|
+
export const DEFAULT_EMAIL_POLL_INTERVAL_SECONDS = 2;
|
|
3
|
+
export const DEFAULT_EMAIL_POLL_PAGE_SIZE = 50;
|
|
4
|
+
export const MAX_EMAIL_POLL_PAGE_SIZE = 100;
|
|
5
|
+
function quoteDslValue(value) {
|
|
6
|
+
if (/^[^\s"]+$/.test(value))
|
|
7
|
+
return value;
|
|
8
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
9
|
+
}
|
|
10
|
+
function combineQ(q, domain) {
|
|
11
|
+
const parts = [
|
|
12
|
+
q?.trim(),
|
|
13
|
+
domain ? `domain:${quoteDslValue(domain.trim())}` : undefined,
|
|
14
|
+
].filter((part) => Boolean(part));
|
|
15
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
16
|
+
}
|
|
17
|
+
export function normalizeIsoDate(value, label) {
|
|
18
|
+
const parsed = new Date(value);
|
|
19
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
20
|
+
throw new Error(`${label} must be a valid date or ISO-8601 timestamp.`);
|
|
21
|
+
}
|
|
22
|
+
return parsed.toISOString();
|
|
23
|
+
}
|
|
24
|
+
export function filtersFromFlags(flags) {
|
|
25
|
+
return {
|
|
26
|
+
body: flags.body,
|
|
27
|
+
domain: flags.domain,
|
|
28
|
+
domainId: flags["domain-id"],
|
|
29
|
+
from: flags.from,
|
|
30
|
+
hasAttachment: flags["has-attachment"],
|
|
31
|
+
q: flags.q,
|
|
32
|
+
spamScoreGte: flags["spam-score-gte"],
|
|
33
|
+
spamScoreLt: flags["spam-score-lt"],
|
|
34
|
+
subject: flags.subject,
|
|
35
|
+
to: flags.to,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function sinceFromFlags(flags) {
|
|
39
|
+
if (flags.since)
|
|
40
|
+
return normalizeIsoDate(flags.since, "--since");
|
|
41
|
+
return flags["include-existing"] ? undefined : new Date().toISOString();
|
|
42
|
+
}
|
|
43
|
+
export function buildEmailSearchQuery(params) {
|
|
44
|
+
const query = {
|
|
45
|
+
include_facets: "false",
|
|
46
|
+
limit: params.pageSize,
|
|
47
|
+
snippet: "false",
|
|
48
|
+
sort: "received_at_asc",
|
|
49
|
+
};
|
|
50
|
+
const q = combineQ(params.filters.q, params.filters.domain);
|
|
51
|
+
if (q)
|
|
52
|
+
query.q = q;
|
|
53
|
+
if (params.filters.body)
|
|
54
|
+
query.body = params.filters.body;
|
|
55
|
+
if (params.filters.domainId)
|
|
56
|
+
query.domain_id = params.filters.domainId;
|
|
57
|
+
if (params.filters.from)
|
|
58
|
+
query.from = params.filters.from;
|
|
59
|
+
if (params.filters.hasAttachment !== undefined) {
|
|
60
|
+
query.has_attachment = params.filters.hasAttachment ? "true" : "false";
|
|
61
|
+
}
|
|
62
|
+
if (params.filters.spamScoreGte !== undefined) {
|
|
63
|
+
query.spam_score_gte = params.filters.spamScoreGte;
|
|
64
|
+
}
|
|
65
|
+
if (params.filters.spamScoreLt !== undefined) {
|
|
66
|
+
query.spam_score_lt = params.filters.spamScoreLt;
|
|
67
|
+
}
|
|
68
|
+
if (params.filters.subject)
|
|
69
|
+
query.subject = params.filters.subject;
|
|
70
|
+
if (params.filters.to)
|
|
71
|
+
query.to = params.filters.to;
|
|
72
|
+
if (params.since)
|
|
73
|
+
query.date_from = params.since;
|
|
74
|
+
if (params.cursor)
|
|
75
|
+
query.cursor = params.cursor;
|
|
76
|
+
return query;
|
|
77
|
+
}
|
|
78
|
+
export function encodeReceivedAtSearchCursor(email) {
|
|
79
|
+
const raw = `r|${new Date(email.received_at).toISOString()}|${email.id}`;
|
|
80
|
+
return Buffer.from(raw, "utf8").toString("base64url");
|
|
81
|
+
}
|
|
82
|
+
export function cursorFromRows(rows) {
|
|
83
|
+
const last = rows.at(-1);
|
|
84
|
+
return last ? encodeReceivedAtSearchCursor(last) : null;
|
|
85
|
+
}
|
|
86
|
+
export function collectNewAcceptedEmails(rows, seenIds) {
|
|
87
|
+
const fresh = [];
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
if (row.status !== "accepted" && row.status !== "completed")
|
|
90
|
+
continue;
|
|
91
|
+
if (seenIds.has(row.id))
|
|
92
|
+
continue;
|
|
93
|
+
seenIds.add(row.id);
|
|
94
|
+
fresh.push(row);
|
|
95
|
+
}
|
|
96
|
+
return fresh;
|
|
97
|
+
}
|
|
98
|
+
export async function fetchEmailSearchPage(params) {
|
|
99
|
+
const result = await searchEmails({
|
|
100
|
+
client: params.apiClient.client,
|
|
101
|
+
query: buildEmailSearchQuery({
|
|
102
|
+
cursor: params.cursor,
|
|
103
|
+
filters: params.filters,
|
|
104
|
+
pageSize: params.pageSize,
|
|
105
|
+
since: params.since,
|
|
106
|
+
}),
|
|
107
|
+
responseStyle: "fields",
|
|
108
|
+
});
|
|
109
|
+
if (result.error)
|
|
110
|
+
return { ok: false, error: result.error };
|
|
111
|
+
const envelope = result.data;
|
|
112
|
+
const rows = envelope?.data ?? [];
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
cursor: envelope?.meta.cursor ?? cursorFromRows(rows),
|
|
116
|
+
rows,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function sleep(ms) {
|
|
120
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
121
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Command, Errors, Flags } from "@oclif/core";
|
|
2
|
+
import { PrimitiveApiClient } from "../../api/index.js";
|
|
3
|
+
import { extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, writeErrorWithHints, } from "../api-command.js";
|
|
4
|
+
import { resolveCliAuth } from "../auth.js";
|
|
5
|
+
import { formatHeader, formatRow, pickIdWidth } from "./emails-latest.js";
|
|
6
|
+
import { collectNewAcceptedEmails, DEFAULT_EMAIL_POLL_INTERVAL_SECONDS, DEFAULT_EMAIL_POLL_PAGE_SIZE, fetchEmailSearchPage, filtersFromFlags, MAX_EMAIL_POLL_PAGE_SIZE, sinceFromFlags, sleep, } from "./emails-poll.js";
|
|
7
|
+
const DEFAULT_WAIT_TIMEOUT_SECONDS = 300;
|
|
8
|
+
function cliError(message) {
|
|
9
|
+
return new Errors.CLIError(message, { exit: 1 });
|
|
10
|
+
}
|
|
11
|
+
class EmailsWaitCommand extends Command {
|
|
12
|
+
static description = "Poll until matching inbound emails arrive, printing each match as it is found.";
|
|
13
|
+
static summary = "Wait for matching inbound emails";
|
|
14
|
+
static examples = [
|
|
15
|
+
"<%= config.bin %> emails wait --to test@example.com",
|
|
16
|
+
"<%= config.bin %> emails wait --subject verify --number 5 --timeout 120",
|
|
17
|
+
"<%= config.bin %> emails wait --q 'domain:example.com' --table",
|
|
18
|
+
];
|
|
19
|
+
static flags = {
|
|
20
|
+
"api-key": Flags.string({
|
|
21
|
+
description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
|
|
22
|
+
env: "PRIMITIVE_API_KEY",
|
|
23
|
+
}),
|
|
24
|
+
"api-base-url-1": Flags.string({
|
|
25
|
+
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
26
|
+
env: "PRIMITIVE_API_BASE_URL_1",
|
|
27
|
+
hidden: true,
|
|
28
|
+
}),
|
|
29
|
+
"api-base-url-2": Flags.string({
|
|
30
|
+
description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
|
|
31
|
+
env: "PRIMITIVE_API_BASE_URL_2",
|
|
32
|
+
hidden: true,
|
|
33
|
+
}),
|
|
34
|
+
body: Flags.string({
|
|
35
|
+
description: "Full-text body filter",
|
|
36
|
+
}),
|
|
37
|
+
domain: Flags.string({
|
|
38
|
+
description: "Filter by inbound email domain",
|
|
39
|
+
}),
|
|
40
|
+
"domain-id": Flags.string({
|
|
41
|
+
description: "Filter by domain UUID",
|
|
42
|
+
}),
|
|
43
|
+
from: Flags.string({
|
|
44
|
+
description: "Filter by sender address or domain",
|
|
45
|
+
}),
|
|
46
|
+
"has-attachment": Flags.boolean({
|
|
47
|
+
description: "Only match emails with one or more attachments",
|
|
48
|
+
}),
|
|
49
|
+
"include-existing": Flags.boolean({
|
|
50
|
+
description: "Start from existing matching emails instead of only new arrivals",
|
|
51
|
+
}),
|
|
52
|
+
interval: Flags.integer({
|
|
53
|
+
default: DEFAULT_EMAIL_POLL_INTERVAL_SECONDS,
|
|
54
|
+
description: "Seconds to wait between empty polls",
|
|
55
|
+
min: 1,
|
|
56
|
+
}),
|
|
57
|
+
number: Flags.integer({
|
|
58
|
+
char: "n",
|
|
59
|
+
default: 1,
|
|
60
|
+
description: "Exit successfully after this many matching emails",
|
|
61
|
+
min: 1,
|
|
62
|
+
}),
|
|
63
|
+
"page-size": Flags.integer({
|
|
64
|
+
default: DEFAULT_EMAIL_POLL_PAGE_SIZE,
|
|
65
|
+
description: `Emails to fetch per poll (1-${MAX_EMAIL_POLL_PAGE_SIZE})`,
|
|
66
|
+
max: MAX_EMAIL_POLL_PAGE_SIZE,
|
|
67
|
+
min: 1,
|
|
68
|
+
}),
|
|
69
|
+
q: Flags.string({
|
|
70
|
+
description: "Full-text search DSL query",
|
|
71
|
+
}),
|
|
72
|
+
since: Flags.string({
|
|
73
|
+
description: "Only match emails received on or after this date/time",
|
|
74
|
+
}),
|
|
75
|
+
"spam-score-gte": Flags.integer({
|
|
76
|
+
description: "Only match emails with spam score greater than or equal to this value",
|
|
77
|
+
}),
|
|
78
|
+
"spam-score-lt": Flags.integer({
|
|
79
|
+
description: "Only match emails with spam score below this value",
|
|
80
|
+
}),
|
|
81
|
+
subject: Flags.string({
|
|
82
|
+
description: "Full-text subject filter",
|
|
83
|
+
}),
|
|
84
|
+
table: Flags.boolean({
|
|
85
|
+
description: "Print a human-readable table instead of JSONL",
|
|
86
|
+
}),
|
|
87
|
+
timeout: Flags.integer({
|
|
88
|
+
default: DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
89
|
+
description: "Seconds to wait before exiting nonzero; 0 waits forever",
|
|
90
|
+
min: 0,
|
|
91
|
+
}),
|
|
92
|
+
to: Flags.string({
|
|
93
|
+
description: "Filter by recipient address or domain",
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
async run() {
|
|
97
|
+
const { flags } = await this.parse(EmailsWaitCommand);
|
|
98
|
+
const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
|
|
99
|
+
flags["api-base-url-2"] !== undefined;
|
|
100
|
+
const auth = resolveCliAuth({
|
|
101
|
+
apiKey: flags["api-key"],
|
|
102
|
+
apiBaseUrl1: flags["api-base-url-1"],
|
|
103
|
+
apiBaseUrl2: flags["api-base-url-2"],
|
|
104
|
+
configDir: this.config.configDir,
|
|
105
|
+
});
|
|
106
|
+
const apiClient = new PrimitiveApiClient({
|
|
107
|
+
apiKey: auth.apiKey,
|
|
108
|
+
apiBaseUrl1: auth.apiBaseUrl1,
|
|
109
|
+
apiBaseUrl2: auth.apiBaseUrl2,
|
|
110
|
+
});
|
|
111
|
+
let since;
|
|
112
|
+
try {
|
|
113
|
+
since = sinceFromFlags(flags);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
throw cliError(error instanceof Error ? error.message : String(error));
|
|
117
|
+
}
|
|
118
|
+
const filters = filtersFromFlags(flags);
|
|
119
|
+
const deadline = flags.timeout === 0 ? null : Date.now() + flags.timeout * 1000;
|
|
120
|
+
const idWidth = pickIdWidth(Boolean(process.stdout.isTTY));
|
|
121
|
+
const seenIds = new Set();
|
|
122
|
+
let cursor = null;
|
|
123
|
+
let matched = 0;
|
|
124
|
+
let headerPrinted = false;
|
|
125
|
+
while (deadline === null || Date.now() < deadline) {
|
|
126
|
+
const page = await fetchEmailSearchPage({
|
|
127
|
+
apiClient,
|
|
128
|
+
cursor,
|
|
129
|
+
filters,
|
|
130
|
+
pageSize: flags["page-size"],
|
|
131
|
+
since,
|
|
132
|
+
});
|
|
133
|
+
if (!page.ok) {
|
|
134
|
+
const payload = extractErrorPayload(page.error);
|
|
135
|
+
writeErrorWithHints(payload);
|
|
136
|
+
removeStaleSavedCredentialOnUnauthorized({
|
|
137
|
+
auth,
|
|
138
|
+
baseUrlOverridden,
|
|
139
|
+
configDir: this.config.configDir,
|
|
140
|
+
payload,
|
|
141
|
+
});
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
cursor = page.cursor ?? cursor;
|
|
146
|
+
for (const email of collectNewAcceptedEmails(page.rows, seenIds)) {
|
|
147
|
+
if (flags.table) {
|
|
148
|
+
if (!headerPrinted) {
|
|
149
|
+
process.stderr.write(`${formatHeader(idWidth)}\n`);
|
|
150
|
+
headerPrinted = true;
|
|
151
|
+
}
|
|
152
|
+
this.log(formatRow(email, idWidth));
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
this.log(JSON.stringify(email));
|
|
156
|
+
}
|
|
157
|
+
matched += 1;
|
|
158
|
+
if (matched >= flags.number)
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (page.rows.length > 0)
|
|
162
|
+
continue;
|
|
163
|
+
if (deadline !== null && Date.now() >= deadline)
|
|
164
|
+
break;
|
|
165
|
+
await sleep(flags.interval * 1000);
|
|
166
|
+
}
|
|
167
|
+
process.stderr.write(`Timed out waiting for ${flags.number} matching email${flags.number === 1 ? "" : "s"}; received ${matched}.\n`);
|
|
168
|
+
process.exitCode = 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export default EmailsWaitCommand;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Command, Errors, Flags } from "@oclif/core";
|
|
2
|
+
import { PrimitiveApiClient } from "../../api/index.js";
|
|
3
|
+
import { extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, writeErrorWithHints, } from "../api-command.js";
|
|
4
|
+
import { resolveCliAuth } from "../auth.js";
|
|
5
|
+
import { formatHeader, formatRow, pickIdWidth } from "./emails-latest.js";
|
|
6
|
+
import { collectNewAcceptedEmails, DEFAULT_EMAIL_POLL_INTERVAL_SECONDS, DEFAULT_EMAIL_POLL_PAGE_SIZE, fetchEmailSearchPage, filtersFromFlags, MAX_EMAIL_POLL_PAGE_SIZE, sinceFromFlags, sleep, } from "./emails-poll.js";
|
|
7
|
+
function cliError(message) {
|
|
8
|
+
return new Errors.CLIError(message, { exit: 1 });
|
|
9
|
+
}
|
|
10
|
+
class EmailsWatchCommand extends Command {
|
|
11
|
+
static description = "Poll for new inbound emails and print matching messages as they arrive.";
|
|
12
|
+
static summary = "Watch inbound emails with filters";
|
|
13
|
+
static examples = [
|
|
14
|
+
"<%= config.bin %> emails watch --to support@example.com",
|
|
15
|
+
"<%= config.bin %> emails watch --subject verify --seconds 300",
|
|
16
|
+
"<%= config.bin %> emails watch --number 20 --jsonl",
|
|
17
|
+
];
|
|
18
|
+
static flags = {
|
|
19
|
+
"api-key": Flags.string({
|
|
20
|
+
description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
|
|
21
|
+
env: "PRIMITIVE_API_KEY",
|
|
22
|
+
}),
|
|
23
|
+
"api-base-url-1": Flags.string({
|
|
24
|
+
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
25
|
+
env: "PRIMITIVE_API_BASE_URL_1",
|
|
26
|
+
hidden: true,
|
|
27
|
+
}),
|
|
28
|
+
"api-base-url-2": Flags.string({
|
|
29
|
+
description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
|
|
30
|
+
env: "PRIMITIVE_API_BASE_URL_2",
|
|
31
|
+
hidden: true,
|
|
32
|
+
}),
|
|
33
|
+
body: Flags.string({
|
|
34
|
+
description: "Full-text body filter",
|
|
35
|
+
}),
|
|
36
|
+
domain: Flags.string({
|
|
37
|
+
description: "Filter by inbound email domain",
|
|
38
|
+
}),
|
|
39
|
+
"domain-id": Flags.string({
|
|
40
|
+
description: "Filter by domain UUID",
|
|
41
|
+
}),
|
|
42
|
+
from: Flags.string({
|
|
43
|
+
description: "Filter by sender address or domain",
|
|
44
|
+
}),
|
|
45
|
+
"has-attachment": Flags.boolean({
|
|
46
|
+
description: "Only show emails with one or more attachments",
|
|
47
|
+
}),
|
|
48
|
+
"include-existing": Flags.boolean({
|
|
49
|
+
description: "Start from existing matching emails instead of only new arrivals",
|
|
50
|
+
}),
|
|
51
|
+
interval: Flags.integer({
|
|
52
|
+
default: DEFAULT_EMAIL_POLL_INTERVAL_SECONDS,
|
|
53
|
+
description: "Seconds to wait between empty polls",
|
|
54
|
+
min: 1,
|
|
55
|
+
}),
|
|
56
|
+
jsonl: Flags.boolean({
|
|
57
|
+
description: "Print each email as one JSON object per line",
|
|
58
|
+
}),
|
|
59
|
+
number: Flags.integer({
|
|
60
|
+
description: "Exit after printing this many matching emails",
|
|
61
|
+
min: 1,
|
|
62
|
+
}),
|
|
63
|
+
"page-size": Flags.integer({
|
|
64
|
+
default: DEFAULT_EMAIL_POLL_PAGE_SIZE,
|
|
65
|
+
description: `Emails to fetch per poll (1-${MAX_EMAIL_POLL_PAGE_SIZE})`,
|
|
66
|
+
max: MAX_EMAIL_POLL_PAGE_SIZE,
|
|
67
|
+
min: 1,
|
|
68
|
+
}),
|
|
69
|
+
q: Flags.string({
|
|
70
|
+
description: "Full-text search DSL query",
|
|
71
|
+
}),
|
|
72
|
+
seconds: Flags.integer({
|
|
73
|
+
description: "Exit after this many seconds",
|
|
74
|
+
min: 1,
|
|
75
|
+
}),
|
|
76
|
+
since: Flags.string({
|
|
77
|
+
description: "Only show emails received on or after this date/time",
|
|
78
|
+
}),
|
|
79
|
+
"spam-score-gte": Flags.integer({
|
|
80
|
+
description: "Only show emails with spam score greater than or equal to this value",
|
|
81
|
+
}),
|
|
82
|
+
"spam-score-lt": Flags.integer({
|
|
83
|
+
description: "Only show emails with spam score below this value",
|
|
84
|
+
}),
|
|
85
|
+
subject: Flags.string({
|
|
86
|
+
description: "Full-text subject filter",
|
|
87
|
+
}),
|
|
88
|
+
to: Flags.string({
|
|
89
|
+
description: "Filter by recipient address or domain",
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
async run() {
|
|
93
|
+
const { flags } = await this.parse(EmailsWatchCommand);
|
|
94
|
+
const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
|
|
95
|
+
flags["api-base-url-2"] !== undefined;
|
|
96
|
+
const auth = resolveCliAuth({
|
|
97
|
+
apiKey: flags["api-key"],
|
|
98
|
+
apiBaseUrl1: flags["api-base-url-1"],
|
|
99
|
+
apiBaseUrl2: flags["api-base-url-2"],
|
|
100
|
+
configDir: this.config.configDir,
|
|
101
|
+
});
|
|
102
|
+
const apiClient = new PrimitiveApiClient({
|
|
103
|
+
apiKey: auth.apiKey,
|
|
104
|
+
apiBaseUrl1: auth.apiBaseUrl1,
|
|
105
|
+
apiBaseUrl2: auth.apiBaseUrl2,
|
|
106
|
+
});
|
|
107
|
+
let since;
|
|
108
|
+
try {
|
|
109
|
+
since = sinceFromFlags(flags);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
throw cliError(error instanceof Error ? error.message : String(error));
|
|
113
|
+
}
|
|
114
|
+
const filters = filtersFromFlags(flags);
|
|
115
|
+
const deadline = flags.seconds ? Date.now() + flags.seconds * 1000 : null;
|
|
116
|
+
const idWidth = pickIdWidth(Boolean(process.stdout.isTTY));
|
|
117
|
+
const seenIds = new Set();
|
|
118
|
+
let cursor = null;
|
|
119
|
+
let printed = 0;
|
|
120
|
+
let headerPrinted = false;
|
|
121
|
+
while (deadline === null || Date.now() < deadline) {
|
|
122
|
+
const page = await fetchEmailSearchPage({
|
|
123
|
+
apiClient,
|
|
124
|
+
cursor,
|
|
125
|
+
filters,
|
|
126
|
+
pageSize: flags["page-size"],
|
|
127
|
+
since,
|
|
128
|
+
});
|
|
129
|
+
if (!page.ok) {
|
|
130
|
+
const payload = extractErrorPayload(page.error);
|
|
131
|
+
writeErrorWithHints(payload);
|
|
132
|
+
removeStaleSavedCredentialOnUnauthorized({
|
|
133
|
+
auth,
|
|
134
|
+
baseUrlOverridden,
|
|
135
|
+
configDir: this.config.configDir,
|
|
136
|
+
payload,
|
|
137
|
+
});
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
cursor = page.cursor ?? cursor;
|
|
142
|
+
for (const email of collectNewAcceptedEmails(page.rows, seenIds)) {
|
|
143
|
+
if (flags.jsonl) {
|
|
144
|
+
this.log(JSON.stringify(email));
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
if (!headerPrinted) {
|
|
148
|
+
process.stderr.write(`${formatHeader(idWidth)}\n`);
|
|
149
|
+
headerPrinted = true;
|
|
150
|
+
}
|
|
151
|
+
this.log(formatRow(email, idWidth));
|
|
152
|
+
}
|
|
153
|
+
printed += 1;
|
|
154
|
+
if (flags.number && printed >= flags.number)
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (page.rows.length > 0)
|
|
158
|
+
continue;
|
|
159
|
+
if (deadline !== null && Date.now() >= deadline)
|
|
160
|
+
break;
|
|
161
|
+
await sleep(flags.interval * 1000);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export default EmailsWatchCommand;
|