@primitivedotdev/sdk 0.19.0 → 0.21.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/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { A as UnknownEvent, C as ParsedDataFailed, D as RawContentDownloadOnly, E as RawContent, M as WebhookAttachment, N as WebhookEvent, O as RawContentInline, S as ParsedDataComplete, T as ParsedStatus, _ as ForwardResultInline, a as DmarcPolicy, b as KnownWebhookEvent, c as EmailAnalysis, d as EventType, f as ForwardAnalysis, g as ForwardResultAttachmentSkipped, h as ForwardResultAttachmentAnalyzed, i as DkimSignature, j as ValidateEmailAuthResult, k as SpfResult, l as EmailAuth, m as ForwardResult, n as AuthVerdict, o as DmarcResult, p as ForwardOriginalSender, r as DkimResult, s as EmailAddress, t as AuthConfidence, u as EmailReceivedEvent, v as ForwardVerdict, w as ParsedError, x as ParsedData, y as ForwardVerification } from "./types-9vXGZjPd.js";
2
2
  import { a as buildReplySubject, c as parseHeaderAddress, i as buildForwardSubject, n as ReceivedEmailAddress, o as formatAddress, r as ReceivedEmailThread, s as normalizeReceivedEmail, t as ReceivedEmail } from "./received-email-DNjpq_Wt.js";
3
- import { a as PrimitiveApiError, c as PrimitiveClientOptions, d as SendInput, f as SendResult, g as createPrimitiveClient, l as ReplyInput, m as client, n as ForwardInput, p as SendThreadInput, s as PrimitiveClient } from "./index-oRkCqj6u.js";
3
+ import { _ as createPrimitiveClient, c as PrimitiveClient, f as SendInput, h as client, l as PrimitiveClientOptions, m as SendThreadInput, o as PrimitiveApiError, p as SendResult, r as ForwardInput, u as ReplyInput } from "./index-QTYQpSFt.js";
4
4
  import { A as VerifyOptions, B as PAYLOAD_ERRORS, C as signStandardWebhooksPayload, D as PRIMITIVE_CONFIRMED_HEADER, E as LEGACY_SIGNATURE_HEADER, F as VerifyDownloadTokenResult, G as VERIFICATION_ERRORS, H as RAW_EMAIL_ERRORS, I as generateDownloadToken, J as WebhookPayloadErrorCode, K as WebhookErrorCode, L as verifyDownloadToken, M as verifyWebhookSignature, N as GenerateDownloadTokenOptions, O as PRIMITIVE_SIGNATURE_HEADER, P as VerifyDownloadTokenOptions, Q as WebhookVerificationErrorCode, R as safeValidateEmailReceivedEvent, S as StandardWebhooksVerifyOptions, T as LEGACY_CONFIRMED_HEADER, U as RawEmailDecodeError, V as PrimitiveWebhookError, W as RawEmailDecodeErrorCode, X as WebhookValidationErrorCode, Y as WebhookValidationError, Z as WebhookVerificationError, _ as emailReceivedEventJsonSchema, a as confirmedHeaders, b as STANDARD_WEBHOOK_TIMESTAMP_HEADER, c as handleWebhook, d as isRawIncluded, f as parseWebhookEvent, g as validateEmailAuth, h as WEBHOOK_VERSION, i as WebhookHeaders, j as signWebhookPayload, k as SignResult, l as isDownloadExpired, m as verifyRawEmailDownload, n as HandleWebhookOptions, o as decodeRawEmail, p as receive, q as WebhookPayloadError, r as ReceiveRequestOptions, s as getDownloadTimeRemaining, t as DecodeRawEmailOptions, u as isEmailReceivedEvent, v as STANDARD_WEBHOOK_ID_HEADER, w as verifyStandardWebhooksSignature, x as StandardWebhooksSignResult, y as STANDARD_WEBHOOK_SIGNATURE_HEADER, z as validateEmailReceivedEvent } from "./index-CDlwyxdp.js";
5
5
 
6
6
  //#region src/index.d.ts
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as parseHeaderAddress, i as normalizeReceivedEmail, n as buildReplySubject, r as formatAddress, t as buildForwardSubject } from "./received-email-D6tKtWwW.js";
2
- import { a as client, i as PrimitiveClient, r as PrimitiveApiError, s as createPrimitiveClient } from "./api-C5VR_Opg.js";
2
+ import { a as PrimitiveClient, c as createPrimitiveClient, i as PrimitiveApiError, o as client } from "./api-BjzvA2Fy.js";
3
3
  import { A as PRIMITIVE_CONFIRMED_HEADER, B as RAW_EMAIL_ERRORS, C as STANDARD_WEBHOOK_ID_HEADER, D as verifyStandardWebhooksSignature, E as signStandardWebhooksPayload, F as verifyDownloadToken, G as WebhookVerificationError, H as VERIFICATION_ERRORS, I as safeValidateEmailReceivedEvent, L as validateEmailReceivedEvent, M as signWebhookPayload, N as verifyWebhookSignature, O as LEGACY_CONFIRMED_HEADER, P as generateDownloadToken, R as PAYLOAD_ERRORS, S as emailReceivedEventJsonSchema, T as STANDARD_WEBHOOK_TIMESTAMP_HEADER, U as WebhookPayloadError, V as RawEmailDecodeError, W as WebhookValidationError, _ as DmarcResult, a as isDownloadExpired, b as ParsedStatus, c as parseWebhookEvent, d as WEBHOOK_VERSION, f as validateEmailAuth, g as DmarcPolicy, h as DkimResult, i as handleWebhook, j as PRIMITIVE_SIGNATURE_HEADER, k as LEGACY_SIGNATURE_HEADER, l as receive, m as AuthVerdict, n as decodeRawEmail, o as isEmailReceivedEvent, p as AuthConfidence, r as getDownloadTimeRemaining, s as isRawIncluded, t as confirmedHeaders, u as verifyRawEmailDownload, v as EventType, w as STANDARD_WEBHOOK_SIGNATURE_HEADER, x as SpfResult, y as ForwardVerdict, z as PrimitiveWebhookError } from "./webhook-rUjGV6Zu.js";
4
4
  //#region src/index.ts
5
5
  const primitive = {
@@ -224,6 +224,19 @@ export function readJsonBody(flags) {
224
224
  }
225
225
  return undefined;
226
226
  }
227
+ // Read a UTF-8 text file off disk, mapping any failure to a CLIError
228
+ // tagged with the originating flag so the user sees which path failed
229
+ // to open. Used by hand-rolled commands that take a file-input flag
230
+ // (e.g. functions:deploy --file).
231
+ export function readTextFileFlag(path, flagLabel) {
232
+ try {
233
+ return readFileSync(path, "utf8");
234
+ }
235
+ catch (error) {
236
+ const detail = error instanceof Error ? error.message : String(error);
237
+ throw cliError(`Could not read ${flagLabel} ${path}: ${detail}`);
238
+ }
239
+ }
227
240
  export function extractErrorPayload(raw) {
228
241
  if (raw &&
229
242
  typeof raw === "object" &&
@@ -320,9 +333,12 @@ export function removeStaleSavedCredentialOnUnauthorized(params) {
320
333
  }
321
334
  const baseUrlDiffersFromSaved = params.baseUrlOverridden &&
322
335
  params.auth.credentials !== null &&
323
- params.auth.baseUrl !== params.auth.credentials.base_url;
336
+ params.auth.apiBaseUrl1 !== params.auth.credentials.api_base_url_1;
324
337
  if (baseUrlDiffersFromSaved) {
325
- process.stderr.write("Saved Primitive CLI credentials were rejected by the overridden API base URL. The local credential was not removed; check --base-url / PRIMITIVE_API_URL, or run `primitive logout` to remove it.\n");
338
+ // Override env vars (PRIMITIVE_API_BASE_URL_1 / _2) are intentionally
339
+ // not advertised in --help; this hint is the only customer-visible
340
+ // mention. They're for internal staging/local testing.
341
+ process.stderr.write("Saved Primitive CLI credentials were rejected by the overridden API base URL. The local credential was not removed; unset PRIMITIVE_API_BASE_URL_1, or run `primitive logout` to remove the stored credential.\n");
326
342
  return false;
327
343
  }
328
344
  deleteCliCredentials(params.configDir);
@@ -368,10 +384,45 @@ export async function runWithTiming(enabled, fn) {
368
384
  // own static flags. Lives here so the flag's description and short
369
385
  // name stay consistent across the hand-coded and generated commands.
370
386
  export const TIME_FLAG_DESCRIPTION = "Print the wall-clock duration of this command to stderr after it completes (e.g. `[time: 1.34s]`). Useful for measuring `--wait` send latency, comparing CLI overhead, or capturing timing in scripts.";
387
+ // Shared description text for the api-base-url override flags. Keeps
388
+ // the wording identical across every command that includes them. The
389
+ // flags themselves are hidden from --help (internal staging/local-only).
390
+ export const API_BASE_URL_1_FLAG_DESCRIPTION = "Override the primary API base URL. Internal testing only; not documented to customers.";
391
+ export const API_BASE_URL_2_FLAG_DESCRIPTION = "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.";
392
+ // Helper: was either api-base-url override set by the caller? Used by
393
+ // removeStaleSavedCredentialOnUnauthorized to decide whether to
394
+ // preserve the saved credential when a 401 comes back.
395
+ export function baseUrlOverriddenFromFlags(flags) {
396
+ return (typeof flags["api-base-url-1"] === "string" ||
397
+ typeof flags["api-base-url-2"] === "string");
398
+ }
399
+ // Helper: resolve auth from a parsed-flags bag. Mirrors what every CLI
400
+ // command does inline so the api-base-url-1 / api-base-url-2 mapping
401
+ // stays in one place as we add more migration knobs.
402
+ export function resolveCliAuthFromFlags(flags, configDir) {
403
+ return resolveCliAuth({
404
+ apiKey: typeof flags["api-key"] === "string"
405
+ ? flags["api-key"]
406
+ : undefined,
407
+ apiBaseUrl1: typeof flags["api-base-url-1"] === "string"
408
+ ? flags["api-base-url-1"]
409
+ : undefined,
410
+ apiBaseUrl2: typeof flags["api-base-url-2"] === "string"
411
+ ? flags["api-base-url-2"]
412
+ : undefined,
413
+ configDir,
414
+ });
415
+ }
416
+ // Operations that route to the attachments-supporting host
417
+ // (apiBaseUrl2) instead of the primary API host. Internal to the CLI:
418
+ // as more operations migrate to host 2 over time, add their generated
419
+ // sdkName here. Today it is just /send-mail.
420
+ const HOST_2_OPERATIONS = new Set(["sendEmail"]);
371
421
  // Reserved flag names the body-field expander must never overwrite.
372
422
  // `--raw-body` and `--body-file` are the JSON escape hatches.
373
- // `--api-key`, `--base-url`, `--output` are infra. Path and query
374
- // params get added before body fields and take precedence.
423
+ // `--api-key`, `--api-base-url-1`, `--api-base-url-2`, `--output` are
424
+ // infra. Path and query params get added before body fields and take
425
+ // precedence.
375
426
  //
376
427
  // Note: `--body` is intentionally NOT reserved here. The naive
377
428
  // agent expectation (per AGX walkthrough) is that --body means
@@ -386,7 +437,8 @@ export const TIME_FLAG_DESCRIPTION = "Print the wall-clock duration of this comm
386
437
  // send` defines its own --body for the message text.
387
438
  const RESERVED_FLAG_NAMES = new Set([
388
439
  "api-key",
389
- "base-url",
440
+ "api-base-url-1",
441
+ "api-base-url-2",
390
442
  "raw-body",
391
443
  "body-file",
392
444
  "output",
@@ -425,9 +477,20 @@ function buildFlags(operation) {
425
477
  description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
426
478
  env: "PRIMITIVE_API_KEY",
427
479
  }),
428
- "base-url": Flags.string({
429
- description: "API base URL (defaults to PRIMITIVE_API_URL or production)",
430
- env: "PRIMITIVE_API_URL",
480
+ // Two override knobs for the dual-host setup. Hidden because they
481
+ // are for internal staging/local testing only. Production users
482
+ // should not override; the defaults route correctly. Env vars
483
+ // PRIMITIVE_API_BASE_URL_1 and PRIMITIVE_API_BASE_URL_2 carry the
484
+ // same semantics. Both are intentionally absent from --help output.
485
+ "api-base-url-1": Flags.string({
486
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
487
+ env: "PRIMITIVE_API_BASE_URL_1",
488
+ hidden: true,
489
+ }),
490
+ "api-base-url-2": Flags.string({
491
+ description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
492
+ env: "PRIMITIVE_API_BASE_URL_2",
493
+ hidden: true,
431
494
  }),
432
495
  time: Flags.boolean({
433
496
  description: TIME_FLAG_DESCRIPTION,
@@ -530,19 +593,24 @@ export function createOperationCommand(operation) {
530
593
  const { flags } = await this.parse(OperationCommand);
531
594
  const parsedFlags = flags;
532
595
  await runWithTiming(parsedFlags.time === true, async () => {
533
- const baseUrlOverridden = typeof parsedFlags["base-url"] === "string";
596
+ const baseUrlOverridden = typeof parsedFlags["api-base-url-1"] === "string" ||
597
+ typeof parsedFlags["api-base-url-2"] === "string";
534
598
  const auth = resolveCliAuth({
535
599
  apiKey: typeof parsedFlags["api-key"] === "string"
536
600
  ? parsedFlags["api-key"]
537
601
  : undefined,
538
- baseUrl: typeof parsedFlags["base-url"] === "string"
539
- ? parsedFlags["base-url"]
602
+ apiBaseUrl1: typeof parsedFlags["api-base-url-1"] === "string"
603
+ ? parsedFlags["api-base-url-1"]
604
+ : undefined,
605
+ apiBaseUrl2: typeof parsedFlags["api-base-url-2"] === "string"
606
+ ? parsedFlags["api-base-url-2"]
540
607
  : undefined,
541
608
  configDir: this.config.configDir,
542
609
  });
543
610
  const apiClient = new PrimitiveApiClient({
544
611
  apiKey: auth.apiKey,
545
- baseUrl: auth.baseUrl,
612
+ apiBaseUrl1: auth.apiBaseUrl1,
613
+ apiBaseUrl2: auth.apiBaseUrl2,
546
614
  });
547
615
  // Two body sources, merged: explicit JSON via --body /
548
616
  // --body-file (the base) plus per-field flags (the
@@ -588,9 +656,15 @@ export function createOperationCommand(operation) {
588
656
  throw new Errors.CLIError(`Operation ${operation.operationId} requires a body. Pass each field as a --flag (see --help) or supply JSON via --raw-body / --body-file.`);
589
657
  }
590
658
  const operationFn = operations[operation.sdkName];
659
+ // Operations in HOST_2_OPERATIONS route to the attachments-
660
+ // supporting send host (apiBaseUrl2). Today that's only
661
+ // sendEmail; the list grows as we migrate more endpoints.
662
+ const targetClient = HOST_2_OPERATIONS.has(operation.sdkName)
663
+ ? apiClient._sendClient
664
+ : apiClient.client;
591
665
  const result = await operationFn({
592
666
  body,
593
- client: apiClient.client,
667
+ client: targetClient,
594
668
  parseAs: operation.binaryResponse ? "blob" : "auto",
595
669
  path: collectValues(operation.pathParams, parsedFlags),
596
670
  query: collectValues(operation.queryParams, parsedFlags),
@@ -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 { DEFAULT_BASE_URL } from "../api/index.js";
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
- base_url: requireString(raw, "base_url"),
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
- export function normalizeBaseUrl(baseUrl) {
41
- const trimmed = baseUrl?.trim();
64
+ function normalize(url, fallback) {
65
+ const trimmed = url?.trim();
42
66
  if (!trimmed)
43
- return DEFAULT_BASE_URL;
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
- baseUrl: normalizeBaseUrl(params.baseUrl),
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
- baseUrl: params.baseUrl
156
- ? normalizeBaseUrl(params.baseUrl)
157
- : credentials.base_url,
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
- baseUrl: normalizeBaseUrl(params.baseUrl),
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:latest",
92
- "<%= config.bin %> emails:latest --limit 25",
93
- "<%= config.bin %> emails:latest | head -1 | awk '{print $1}' # full UUID since piped",
94
- "<%= config.bin %> emails:latest --json | jq '.data[0].id'",
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 (defaults to PRIMITIVE_API_URL or production)",
103
- env: "PRIMITIVE_API_URL",
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
- baseUrl: flags["base-url"],
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
- baseUrl: auth.baseUrl,
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
- const header = `${"ID".padEnd(idWidth)} ${"RECEIVED (UTC)".padEnd(RECEIVED_DISPLAY_WIDTH)} ${"FROM".padEnd(ADDRESS_DISPLAY_WIDTH)} ${"TO".padEnd(ADDRESS_DISPLAY_WIDTH)} SUBJECT`;
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
+ }