@primitivedotdev/cli 0.24.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.
@@ -0,0 +1,223 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DEFAULT_API_BASE_URL_1, DEFAULT_API_BASE_URL_2, } from "@primitivedotdev/sdk/api";
5
+ const CREDENTIALS_FILE = "credentials.json";
6
+ const CREDENTIALS_LOCK_DIR = "credentials.lock";
7
+ const CREDENTIALS_LOCK_STALE_MS = 30 * 60 * 1000;
8
+ const MALFORMED_CREDENTIALS_HINT = "Run `primitive logout` and then `primitive login`.";
9
+ function isRecord(value) {
10
+ return value !== null && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+ function requireString(value, key) {
13
+ const raw = value[key];
14
+ if (typeof raw !== "string" || raw.trim().length === 0) {
15
+ throw new Error(`Stored Primitive CLI credentials are malformed: ${key} must be a non-empty string. ${MALFORMED_CREDENTIALS_HINT}`);
16
+ }
17
+ return raw;
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
+ }
33
+ function parseCredentials(raw) {
34
+ if (!isRecord(raw)) {
35
+ throw new Error(`Stored Primitive CLI credentials are malformed: expected a JSON object. ${MALFORMED_CREDENTIALS_HINT}`);
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
+ }
47
+ const orgName = raw.org_name;
48
+ if (orgName !== null && typeof orgName !== "string") {
49
+ throw new Error(`Stored Primitive CLI credentials are malformed: org_name must be a string or null. ${MALFORMED_CREDENTIALS_HINT}`);
50
+ }
51
+ return {
52
+ api_key: requireString(raw, "api_key"),
53
+ key_id: requireString(raw, "key_id"),
54
+ key_prefix: requireString(raw, "key_prefix"),
55
+ org_id: requireString(raw, "org_id"),
56
+ org_name: orgName,
57
+ api_base_url_1: requireString(raw, "api_base_url_1"),
58
+ created_at: requireString(raw, "created_at"),
59
+ };
60
+ }
61
+ export function credentialsPath(configDir) {
62
+ return join(configDir, CREDENTIALS_FILE);
63
+ }
64
+ function normalize(url, fallback) {
65
+ const trimmed = url?.trim();
66
+ if (!trimmed)
67
+ return fallback;
68
+ return trimmed.replace(/\/+$/, "");
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
+ }
76
+ export function loadCliCredentials(configDir) {
77
+ const path = credentialsPath(configDir);
78
+ let contents;
79
+ try {
80
+ contents = readFileSync(path, "utf8");
81
+ }
82
+ catch (error) {
83
+ if (error &&
84
+ typeof error === "object" &&
85
+ error.code === "ENOENT") {
86
+ return null;
87
+ }
88
+ const detail = error instanceof Error ? error.message : String(error);
89
+ throw new Error(`Could not read Primitive CLI credentials: ${detail}`);
90
+ }
91
+ try {
92
+ return parseCredentials(JSON.parse(contents));
93
+ }
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
+ }
113
+ if (error instanceof SyntaxError) {
114
+ throw new Error("Stored Primitive CLI credentials are not valid JSON. Run `primitive logout` and then `primitive login`.");
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ export function saveCliCredentials(configDir, credentials) {
120
+ mkdirSync(configDir, { mode: 0o700, recursive: true });
121
+ const path = credentialsPath(configDir);
122
+ const tempPath = join(configDir, `${CREDENTIALS_FILE}.${process.pid}.${randomUUID()}.tmp`);
123
+ try {
124
+ writeFileSync(tempPath, `${JSON.stringify(credentials, null, 2)}\n`, {
125
+ mode: 0o600,
126
+ });
127
+ chmodSync(tempPath, 0o600);
128
+ renameSync(tempPath, path);
129
+ chmodSync(path, 0o600);
130
+ }
131
+ catch (error) {
132
+ rmSync(tempPath, { force: true });
133
+ throw error;
134
+ }
135
+ }
136
+ export function deleteCliCredentials(configDir) {
137
+ rmSync(credentialsPath(configDir), { force: true });
138
+ }
139
+ function errorCode(error) {
140
+ return error && typeof error === "object"
141
+ ? error.code
142
+ : undefined;
143
+ }
144
+ function removeStaleCliCredentialsLock(lockPath, staleMs, now) {
145
+ try {
146
+ const stats = statSync(lockPath);
147
+ if (now() - stats.mtimeMs < staleMs)
148
+ return false;
149
+ }
150
+ catch (error) {
151
+ if (errorCode(error) === "ENOENT")
152
+ return true;
153
+ throw error;
154
+ }
155
+ rmSync(lockPath, { force: true, recursive: true });
156
+ return true;
157
+ }
158
+ export function acquireCliCredentialsLock(configDir, options = {}) {
159
+ mkdirSync(configDir, { mode: 0o700, recursive: true });
160
+ const lockPath = join(configDir, CREDENTIALS_LOCK_DIR);
161
+ const now = options.now ?? Date.now;
162
+ const staleMs = options.staleMs ?? CREDENTIALS_LOCK_STALE_MS;
163
+ let acquired = false;
164
+ for (let attempt = 0; attempt < 2; attempt += 1) {
165
+ try {
166
+ mkdirSync(lockPath, { mode: 0o700 });
167
+ acquired = true;
168
+ break;
169
+ }
170
+ catch (error) {
171
+ if (errorCode(error) !== "EEXIST")
172
+ throw error;
173
+ if (removeStaleCliCredentialsLock(lockPath, staleMs, now))
174
+ continue;
175
+ throw new Error("Another Primitive CLI credential operation is already in progress. Wait for it to finish, then retry.");
176
+ }
177
+ }
178
+ if (!acquired) {
179
+ throw new Error("Another Primitive CLI credential operation is already in progress. Wait for it to finish, then retry.");
180
+ }
181
+ let released = false;
182
+ return () => {
183
+ if (released)
184
+ return;
185
+ released = true;
186
+ rmSync(lockPath, { force: true, recursive: true });
187
+ };
188
+ }
189
+ export function resolveCliAuth(params) {
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);
195
+ if (apiKey) {
196
+ return {
197
+ apiKey,
198
+ apiBaseUrl1: normalizeApiBaseUrl1(params.apiBaseUrl1),
199
+ apiBaseUrl2,
200
+ credentials: null,
201
+ source: "flag-or-env",
202
+ };
203
+ }
204
+ const credentials = loadCliCredentials(params.configDir);
205
+ if (credentials) {
206
+ return {
207
+ apiKey: credentials.api_key,
208
+ apiBaseUrl1: params.apiBaseUrl1
209
+ ? normalizeApiBaseUrl1(params.apiBaseUrl1)
210
+ : credentials.api_base_url_1,
211
+ apiBaseUrl2,
212
+ credentials,
213
+ source: "stored",
214
+ };
215
+ }
216
+ return {
217
+ apiKey: undefined,
218
+ apiBaseUrl1: normalizeApiBaseUrl1(params.apiBaseUrl1),
219
+ apiBaseUrl2,
220
+ credentials: null,
221
+ source: "none",
222
+ };
223
+ }
@@ -0,0 +1,184 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { listEmails, PrimitiveApiClient } from "@primitivedotdev/sdk/api";
3
+ import { extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
4
+ import { resolveCliAuth } from "../auth.js";
5
+ // `primitive emails:latest` is the agent-grade shortcut for "show me
6
+ // the most recent inbound emails as something I can read at a glance."
7
+ // `emails:list-emails` returns the full JSON envelope which is great
8
+ // for piping but blows out the screen for a quick triage. The AGX
9
+ // walkthrough flagged that an agent doing inbox triage had no compact
10
+ // view to reach for; `latest` is that view.
11
+ //
12
+ // Output is a fixed-width text table: short-id, received timestamp,
13
+ // from address, to address, and subject. Subject is truncated for
14
+ // display only; the underlying JSON is unchanged. For machine-readable
15
+ // output, callers should use `emails:list-emails` and pipe to jq.
16
+ const DEFAULT_LIMIT = 10;
17
+ const MAX_LIMIT = 100;
18
+ // Truncation widths chosen so a row fits in ~140 columns total. Long
19
+ // values wrap to "..." rather than blowing out terminal layout.
20
+ const SUBJECT_DISPLAY_WIDTH = 50;
21
+ const ADDRESS_DISPLAY_WIDTH = 32;
22
+ // Two ID widths: the short prefix is for human eyes (interactive
23
+ // TTY), the full UUID is for piped output (a script reading the row
24
+ // as a feed). The short prefix is useless when piped because every
25
+ // other operation requires the full UUID, so the AGX walkthrough
26
+ // kept producing a re-run with `--json` just to recover the id.
27
+ // Auto-switching by `process.stdout.isTTY` makes the common piped
28
+ // case a one-call workflow.
29
+ const ID_DISPLAY_WIDTH_SHORT = 8;
30
+ const ID_DISPLAY_WIDTH_FULL = 36;
31
+ const RECEIVED_DISPLAY_WIDTH = 19;
32
+ // Truncate to width with right-padding; values longer than width are
33
+ // cut to width-3 with a "..." suffix so the output is exactly `width`
34
+ // chars (3 of which are the ellipsis). Display-only; never mutates
35
+ // the underlying value the caller passed in.
36
+ //
37
+ // Width-exact output matters here: formatRow relies on each column
38
+ // being exactly its declared width so columns line up across rows.
39
+ // An overflowing truncate would shift every later column to the
40
+ // right whenever truncation fired (e.g. a row with both addresses
41
+ // truncated would push SUBJECT 4 chars off).
42
+ export function truncate(value, width) {
43
+ if (value.length <= width)
44
+ return value.padEnd(width);
45
+ return `${value.slice(0, width - 3)}...`;
46
+ }
47
+ // Compact ISO timestamp for display: `YYYY-MM-DD HH:MM:SS` in UTC.
48
+ // The full ISO string with milliseconds and `T`/`Z` markers is too
49
+ // dense to scan at a glance; this is the same shape git log uses.
50
+ export function formatReceivedAt(value) {
51
+ if (!value)
52
+ return "-".padEnd(RECEIVED_DISPLAY_WIDTH);
53
+ const d = new Date(value);
54
+ if (Number.isNaN(d.getTime()))
55
+ return value.padEnd(RECEIVED_DISPLAY_WIDTH);
56
+ const pad = (n) => String(n).padStart(2, "0");
57
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
58
+ }
59
+ // Decide whether to print the full UUID or the short 8-char prefix
60
+ // based on whether stdout is a TTY. Piped/redirected stdout (the
61
+ // caller is consuming the rows programmatically) gets full UUIDs;
62
+ // interactive terminals get the compact prefix. Pulled out as a
63
+ // helper so tests can drive the rendering branch without touching
64
+ // process.stdout.
65
+ export function pickIdWidth(isTty) {
66
+ return isTty ? ID_DISPLAY_WIDTH_SHORT : ID_DISPLAY_WIDTH_FULL;
67
+ }
68
+ export function formatRow(email, idWidth) {
69
+ // idWidth is one of ID_DISPLAY_WIDTH_SHORT or ID_DISPLAY_WIDTH_FULL.
70
+ // For SHORT, slice the UUID to the prefix length and pad. For FULL,
71
+ // pad to the full UUID width (UUIDs are already 36 chars, so this
72
+ // is effectively just an alignment guarantee for any malformed
73
+ // shorter id).
74
+ const id = truncate(email.id.slice(0, idWidth), idWidth);
75
+ const received = formatReceivedAt(email.received_at);
76
+ const from = truncate(email.sender ?? "", ADDRESS_DISPLAY_WIDTH);
77
+ const to = truncate(email.recipient ?? "", ADDRESS_DISPLAY_WIDTH);
78
+ const subject = (email.subject ?? "").replace(/\s+/g, " ");
79
+ const subjectCol = truncate(subject, SUBJECT_DISPLAY_WIDTH);
80
+ return `${id} ${received} ${from} ${to} ${subjectCol}`;
81
+ }
82
+ export function formatHeader(idWidth) {
83
+ return `${"ID".padEnd(idWidth)} ${"RECEIVED (UTC)".padEnd(RECEIVED_DISPLAY_WIDTH)} ${"FROM".padEnd(ADDRESS_DISPLAY_WIDTH)} ${"TO".padEnd(ADDRESS_DISPLAY_WIDTH)} SUBJECT`;
84
+ }
85
+ class EmailsLatestCommand extends Command {
86
+ 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.
87
+
88
+ ID display is TTY-aware. When STDOUT is a terminal, the table truncates each row's id to the first ${ID_DISPLAY_WIDTH_SHORT} characters for readability. When STDOUT is piped or redirected (the row stream is being consumed by another command), the full UUID is printed so the id can be fed straight back into \`emails:get-email\`, \`emails:delete-email\`, etc. without a separate \`--json\` round-trip.
89
+
90
+ 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.`;
91
+ static summary = "Show the most recent inbound emails as a compact table";
92
+ static examples = [
93
+ "<%= config.bin %> emails latest",
94
+ "<%= config.bin %> emails latest --limit 25",
95
+ "<%= config.bin %> emails latest | head -1 | awk '{print $1}' # full UUID since piped",
96
+ "<%= config.bin %> emails latest --json | jq '.data[0].id'",
97
+ ];
98
+ static flags = {
99
+ "api-key": Flags.string({
100
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
101
+ env: "PRIMITIVE_API_KEY",
102
+ }),
103
+ "api-base-url-1": Flags.string({
104
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
105
+ env: "PRIMITIVE_API_BASE_URL_1",
106
+ hidden: true,
107
+ }),
108
+ "api-base-url-2": Flags.string({
109
+ description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
110
+ env: "PRIMITIVE_API_BASE_URL_2",
111
+ hidden: true,
112
+ }),
113
+ limit: Flags.integer({
114
+ description: `Number of rows to print (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT}).`,
115
+ default: DEFAULT_LIMIT,
116
+ // oclif validates min/max at parse time and emits a consistent
117
+ // out-of-range error before run() is reached, so no manual
118
+ // bounds check is needed here.
119
+ min: 1,
120
+ max: MAX_LIMIT,
121
+ }),
122
+ json: Flags.boolean({
123
+ description: "Print the raw response envelope (with full UUIDs and meta) as JSON on STDOUT instead of the text table. Useful for piping into `jq`, capturing ids for follow-up commands, or scripting.",
124
+ }),
125
+ time: Flags.boolean({
126
+ description: TIME_FLAG_DESCRIPTION,
127
+ }),
128
+ };
129
+ async run() {
130
+ const { flags } = await this.parse(EmailsLatestCommand);
131
+ await runWithTiming(flags.time, async () => {
132
+ const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
133
+ flags["api-base-url-2"] !== undefined;
134
+ const auth = resolveCliAuth({
135
+ apiKey: flags["api-key"],
136
+ apiBaseUrl1: flags["api-base-url-1"],
137
+ apiBaseUrl2: flags["api-base-url-2"],
138
+ configDir: this.config.configDir,
139
+ });
140
+ const apiClient = new PrimitiveApiClient({
141
+ apiKey: auth.apiKey,
142
+ apiBaseUrl1: auth.apiBaseUrl1,
143
+ apiBaseUrl2: auth.apiBaseUrl2,
144
+ });
145
+ const result = await listEmails({
146
+ client: apiClient.client,
147
+ query: { limit: flags.limit },
148
+ responseStyle: "fields",
149
+ });
150
+ if (result.error) {
151
+ const errorPayload = extractErrorPayload(result.error);
152
+ writeErrorWithHints(errorPayload);
153
+ removeStaleSavedCredentialOnUnauthorized({
154
+ auth,
155
+ baseUrlOverridden,
156
+ configDir: this.config.configDir,
157
+ payload: errorPayload,
158
+ });
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ const envelope = result.data;
163
+ if (flags.json) {
164
+ // Raw envelope on stdout. Mirrors the shape `emails:list-emails`
165
+ // emits so callers can swap one for the other when they want
166
+ // table vs json without remembering different command names.
167
+ this.log(JSON.stringify(envelope ?? null, null, 2));
168
+ return;
169
+ }
170
+ const rows = envelope?.data ?? [];
171
+ if (rows.length === 0) {
172
+ process.stderr.write("No inbound emails yet. Send an email to one of your verified domains to populate this list.\n");
173
+ return;
174
+ }
175
+ const idWidth = pickIdWidth(Boolean(process.stdout.isTTY));
176
+ // Header on stderr so the table itself stays grep-friendly.
177
+ process.stderr.write(`${formatHeader(idWidth)}\n`);
178
+ for (const row of rows) {
179
+ this.log(formatRow(row, idWidth));
180
+ }
181
+ });
182
+ }
183
+ }
184
+ export default EmailsLatestCommand;
@@ -0,0 +1,121 @@
1
+ import { searchEmails } from "@primitivedotdev/sdk/api";
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
+ }