@primitivedotdev/sdk 0.14.0 → 0.16.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.
@@ -19,7 +19,15 @@ const MAX_LIMIT = 100;
19
19
  // values wrap to "..." rather than blowing out terminal layout.
20
20
  const SUBJECT_DISPLAY_WIDTH = 50;
21
21
  const ADDRESS_DISPLAY_WIDTH = 32;
22
- const ID_DISPLAY_WIDTH = 8;
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;
23
31
  const RECEIVED_DISPLAY_WIDTH = 19;
24
32
  // Truncate to width with right-padding; values longer than width are
25
33
  // cut to width-3 with a "..." suffix so the output is exactly `width`
@@ -48,8 +56,22 @@ export function formatReceivedAt(value) {
48
56
  const pad = (n) => String(n).padStart(2, "0");
49
57
  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
50
58
  }
51
- export function formatRow(email) {
52
- const id = truncate(email.id.slice(0, ID_DISPLAY_WIDTH), ID_DISPLAY_WIDTH);
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);
53
75
  const received = formatReceivedAt(email.received_at);
54
76
  const from = truncate(email.sender ?? "", ADDRESS_DISPLAY_WIDTH);
55
77
  const to = truncate(email.recipient ?? "", ADDRESS_DISPLAY_WIDTH);
@@ -58,13 +80,17 @@ export function formatRow(email) {
58
80
  return `${id} ${received} ${from} ${to} ${subjectCol}`;
59
81
  }
60
82
  class EmailsLatestCommand extends Command {
61
- 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).
83
+ 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.
84
+
85
+ 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.
62
86
 
63
- The displayed id is the first ${ID_DISPLAY_WIDTH} characters of the email's UUID; pass the full UUID (from \`emails:list-emails\` or \`emails:get-email\`) to operations that need it.`;
87
+ 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.`;
64
88
  static summary = "Show the most recent inbound emails as a compact table";
65
89
  static examples = [
66
90
  "<%= config.bin %> emails:latest",
67
91
  "<%= config.bin %> emails:latest --limit 25",
92
+ "<%= config.bin %> emails:latest | head -1 | awk '{print $1}' # full UUID since piped",
93
+ "<%= config.bin %> emails:latest --json | jq '.data[0].id'",
68
94
  ];
69
95
  static flags = {
70
96
  "api-key": Flags.string({
@@ -84,6 +110,9 @@ class EmailsLatestCommand extends Command {
84
110
  min: 1,
85
111
  max: MAX_LIMIT,
86
112
  }),
113
+ json: Flags.boolean({
114
+ 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.",
115
+ }),
87
116
  };
88
117
  async run() {
89
118
  const { flags } = await this.parse(EmailsLatestCommand);
@@ -102,16 +131,24 @@ class EmailsLatestCommand extends Command {
102
131
  return;
103
132
  }
104
133
  const envelope = result.data;
134
+ if (flags.json) {
135
+ // Raw envelope on stdout. Mirrors the shape `emails:list-emails`
136
+ // emits so callers can swap one for the other when they want
137
+ // table vs json without remembering different command names.
138
+ this.log(JSON.stringify(envelope ?? null, null, 2));
139
+ return;
140
+ }
105
141
  const rows = envelope?.data ?? [];
106
142
  if (rows.length === 0) {
107
143
  process.stderr.write("No inbound emails yet. Send an email to one of your verified domains to populate this list.\n");
108
144
  return;
109
145
  }
146
+ const idWidth = pickIdWidth(Boolean(process.stdout.isTTY));
110
147
  // Header on stderr so the table itself stays grep-friendly.
111
- const header = `${"ID".padEnd(ID_DISPLAY_WIDTH)} ${"RECEIVED (UTC)".padEnd(RECEIVED_DISPLAY_WIDTH)} ${"FROM".padEnd(ADDRESS_DISPLAY_WIDTH)} ${"TO".padEnd(ADDRESS_DISPLAY_WIDTH)} SUBJECT`;
148
+ const header = `${"ID".padEnd(idWidth)} ${"RECEIVED (UTC)".padEnd(RECEIVED_DISPLAY_WIDTH)} ${"FROM".padEnd(ADDRESS_DISPLAY_WIDTH)} ${"TO".padEnd(ADDRESS_DISPLAY_WIDTH)} SUBJECT`;
112
149
  process.stderr.write(`${header}\n`);
113
150
  for (const row of rows) {
114
- this.log(formatRow(row));
151
+ this.log(formatRow(row, idWidth));
115
152
  }
116
153
  }
117
154
  }
@@ -103,6 +103,7 @@ class SendCommand extends Command {
103
103
  "<%= config.bin %> send --to alice@example.com --from support@yourcompany.com --subject 'Quick question' --body 'Are you free Thursday?'",
104
104
  "<%= config.bin %> send --to alice@example.com --html '<p>Hello!</p>'",
105
105
  "<%= config.bin %> send --to alice@example.com --body 'Confirmed' --wait",
106
+ "<%= config.bin %> send --to inbox@your-managed-domain.primitive.email --body 'self-loop smoke test' --wait # any *.primitive.email address routes back to the sending account; useful for proving outbound + inbound work end-to-end",
106
107
  ];
107
108
  static flags = {
108
109
  "api-key": Flags.string({
@@ -50,7 +50,16 @@ class DescribeCommand extends Command {
50
50
  };
51
51
  static description = `Print the full operation manifest entry for a single API command, including the path, request schema, response schema, and per-field descriptions sourced from the OpenAPI spec.
52
52
 
53
- Useful for clarifying response field meanings (e.g. on inbound EmailDetail, which of \`sender\`, \`from_email\`, \`from_header\`, and \`smtp_mail_from\` to read), confirming required body fields, or checking a path's parameter shape before composing a request.`;
53
+ The manifest entry's \`responseSchema\` carries the inlined JSON Schema for the operation's 200/201 \`data\` envelope contents (\`$ref\`s resolved). Use it to look up what specific response fields mean. Examples:
54
+
55
+ # Which of EmailDetail's sender-shaped fields is canonical?
56
+ primitive describe emails:get-email | jq '.responseSchema.properties | keys'
57
+ primitive describe emails:get-email | jq -r '.responseSchema.properties.from_email.description'
58
+
59
+ # What does each value of SentEmailStatus mean?
60
+ primitive describe sending:get-sent-email | jq -r '.responseSchema.properties.status.description'
61
+
62
+ \`requestSchema\` is the same shape for the request body when one exists. For a single field across many operations at once, use \`primitive list-operations | jq\` instead.`;
54
63
  static summary = "Describe a single API operation in detail";
55
64
  static examples = [
56
65
  "<%= config.bin %> describe emails:get-email",
@@ -37,6 +37,12 @@ type PrimitiveOperationManifest = {
37
37
  * true. `$ref`s into the OpenAPI components are inlined.
38
38
  */
39
39
  requestSchema: Record<string, unknown> | null;
40
+ /**
41
+ * Resolved JSON Schema for the 200/201 response body's `data`
42
+ * envelope contents. Same shape as `requestSchema`: `$ref`s
43
+ * inlined. Null on operations without a 200/201 JSON response.
44
+ */
45
+ responseSchema: Record<string, unknown> | null;
40
46
  sdkName: string;
41
47
  summary: string | null;
42
48
  tag: string;
@@ -557,15 +557,9 @@ export const openapiDocument = {
557
557
  "name": "status",
558
558
  "in": "query",
559
559
  "schema": {
560
- "type": "string",
561
- "enum": [
562
- "pending",
563
- "accepted",
564
- "completed",
565
- "rejected"
566
- ]
560
+ "$ref": "#/components/schemas/EmailStatus"
567
561
  },
568
- "description": "Filter by email status"
562
+ "description": "Filter inbound rows by lifecycle status. See `EmailStatus`\nfor what each value means. Note that the webhook delivery\nstate is a SEPARATE lifecycle on the same row; filter by\n`webhook_status` semantics is not currently supported on\nthis endpoint.\n"
569
563
  },
570
564
  {
571
565
  "name": "search",
@@ -638,7 +632,8 @@ export const openapiDocument = {
638
632
  ],
639
633
  "get": {
640
634
  "operationId": "getEmail",
641
- "summary": "Get email details",
635
+ "summary": "Get inbound email by id",
636
+ "description": "Returns the full record for an inbound email received at one\nof your verified domains, including the parsed text and HTML\nbodies, threading metadata, SMTP envelope detail, webhook\ndelivery state, and a `replies` array for any outbound sends\nrecorded as replies to this inbound.\n\nFor listing inbound emails (with cursor pagination, status\nand date filters, and free-text search), use\n`/emails`. Outbound (sent) email records are NOT returned\nhere; use `/sent-emails/{id}` for those.\n\nThe response carries four sender-shaped fields whose\nmeanings overlap. `from_email` is the canonical \"who sent\nthis\" field for most use cases (parsed bare address from\nthe `From:` header, with a `sender` fallback). `from_header`\nis the raw header including any display name. `sender` and\n`smtp_mail_from` both carry the SMTP envelope MAIL FROM\n(return-path) and are equal by construction; `sender` is\nthe older field name retained for compatibility. See\n`primitive describe emails:get-email | jq '.responseSchema.properties'`\nfor per-field detail.\n",
642
637
  "tags": [
643
638
  "Emails"
644
639
  ],
@@ -983,6 +978,66 @@ export const openapiDocument = {
983
978
  }
984
979
  }
985
980
  },
981
+ "/emails/{id}/discard-content": {
982
+ "parameters": [
983
+ {
984
+ "$ref": "#/components/parameters/ResourceId"
985
+ }
986
+ ],
987
+ "post": {
988
+ "operationId": "discardEmailContent",
989
+ "summary": "Discard email content",
990
+ "description": "Permanently deletes the email's raw bytes, parsed body (text + HTML),\nand attachments while preserving metadata (sender, recipient,\nsubject, timestamps, hashes, attachment manifest) for audit logs.\nIdempotent: a second call returns success with\n`already_discarded: true` and does no work.\n\n**Gated** on the customer's discard-content opt-in (managed in the\ndashboard at Settings > Webhooks). When the toggle is off, this\nendpoint returns `403` with code `discard_not_enabled` and a\nmessage pointing the human at the dashboard. There is intentionally\nno API to flip this toggle — opting in to a destructive,\nnon-reversible operation must be a deliberate human click in the\nUI.\n",
991
+ "tags": [
992
+ "Emails"
993
+ ],
994
+ "responses": {
995
+ "200": {
996
+ "description": "Discard result",
997
+ "content": {
998
+ "application/json": {
999
+ "schema": {
1000
+ "allOf": [
1001
+ {
1002
+ "$ref": "#/components/schemas/SuccessEnvelope"
1003
+ },
1004
+ {
1005
+ "type": "object",
1006
+ "properties": {
1007
+ "data": {
1008
+ "$ref": "#/components/schemas/DiscardContentResult"
1009
+ }
1010
+ },
1011
+ "required": [
1012
+ "data"
1013
+ ]
1014
+ }
1015
+ ]
1016
+ }
1017
+ }
1018
+ }
1019
+ },
1020
+ "400": {
1021
+ "$ref": "#/components/responses/ValidationError"
1022
+ },
1023
+ "401": {
1024
+ "$ref": "#/components/responses/Unauthorized"
1025
+ },
1026
+ "403": {
1027
+ "$ref": "#/components/responses/Forbidden"
1028
+ },
1029
+ "404": {
1030
+ "$ref": "#/components/responses/NotFound"
1031
+ },
1032
+ "429": {
1033
+ "$ref": "#/components/responses/RateLimited"
1034
+ },
1035
+ "500": {
1036
+ "$ref": "#/components/responses/InternalError"
1037
+ }
1038
+ }
1039
+ }
1040
+ },
986
1041
  "/endpoints": {
987
1042
  "get": {
988
1043
  "operationId": "listEndpoints",
@@ -2101,6 +2156,7 @@ export const openapiDocument = {
2101
2156
  "outbound_capacity_exhausted",
2102
2157
  "outbound_response_malformed",
2103
2158
  "outbound_relay_failed",
2159
+ "discard_not_enabled",
2104
2160
  "inbound_not_repliable"
2105
2161
  ]
2106
2162
  },
@@ -2614,13 +2670,7 @@ export const openapiDocument = {
2614
2670
  "format": "uuid"
2615
2671
  },
2616
2672
  "status": {
2617
- "type": "string",
2618
- "enum": [
2619
- "pending",
2620
- "accepted",
2621
- "completed",
2622
- "rejected"
2623
- ]
2673
+ "$ref": "#/components/schemas/EmailStatus"
2624
2674
  },
2625
2675
  "sender": {
2626
2676
  "type": "string",
@@ -2659,18 +2709,7 @@ export const openapiDocument = {
2659
2709
  ]
2660
2710
  },
2661
2711
  "webhook_status": {
2662
- "type": [
2663
- "string",
2664
- "null"
2665
- ],
2666
- "enum": [
2667
- "pending",
2668
- "in_flight",
2669
- "fired",
2670
- "failed",
2671
- "exhausted",
2672
- null
2673
- ]
2712
+ "$ref": "#/components/schemas/EmailWebhookStatus"
2674
2713
  },
2675
2714
  "webhook_attempt_count": {
2676
2715
  "type": "integer"
@@ -2742,13 +2781,7 @@ export const openapiDocument = {
2742
2781
  "description": "HTML body parsed from the inbound MIME, matching the `email.parsed.body_html` field on the webhook payload. Null when the message had no HTML part or parsing failed."
2743
2782
  },
2744
2783
  "status": {
2745
- "type": "string",
2746
- "enum": [
2747
- "pending",
2748
- "accepted",
2749
- "completed",
2750
- "rejected"
2751
- ]
2784
+ "$ref": "#/components/schemas/EmailStatus"
2752
2785
  },
2753
2786
  "domain": {
2754
2787
  "type": "string"
@@ -2786,18 +2819,7 @@ export const openapiDocument = {
2786
2819
  ]
2787
2820
  },
2788
2821
  "webhook_status": {
2789
- "type": [
2790
- "string",
2791
- "null"
2792
- ],
2793
- "enum": [
2794
- "pending",
2795
- "in_flight",
2796
- "fired",
2797
- "failed",
2798
- "exhausted",
2799
- null
2800
- ]
2822
+ "$ref": "#/components/schemas/EmailWebhookStatus"
2801
2823
  },
2802
2824
  "webhook_attempt_count": {
2803
2825
  "type": "integer"
@@ -3009,6 +3031,31 @@ export const openapiDocument = {
3009
3031
  "subject"
3010
3032
  ]
3011
3033
  },
3034
+ "EmailStatus": {
3035
+ "type": "string",
3036
+ "description": "Lifecycle status of an INBOUND email (a row in the `emails`\ntable). Distinct from `SentEmailStatus`, which describes\nthe OUTBOUND lifecycle (the `sent_emails` table) and uses\na different vocabulary because the lifecycles differ.\nPossible values:\n\n - `pending`: the row was inserted at ingestion (mx_main)\n and has not yet completed the spam / filter / auth\n pipeline. Body and parsed fields are present; webhook\n delivery is not yet scheduled. Most rows transition out\n of `pending` within seconds.\n - `accepted`: the inbound passed the policy gates and is\n queued for webhook delivery. The `webhook_status` field\n tracks the separate webhook-delivery lifecycle from\n this point.\n - `completed`: terminal success. Webhook delivery\n attempted and acknowledged by every active endpoint, OR\n no endpoints are configured, so the row is durably\n archived.\n - `rejected`: terminal failure at ingestion (spam, blocked\n sender, filter rule, malformed). The body and metadata\n are stored for auditing but no webhook fires and the\n row is not repliable.\n\nSee also `webhook_status` (separate enum tracking the\nwebhook-delivery state machine) and `SentEmailStatus` (the\noutbound vocabulary).\n",
3037
+ "enum": [
3038
+ "pending",
3039
+ "accepted",
3040
+ "completed",
3041
+ "rejected"
3042
+ ]
3043
+ },
3044
+ "EmailWebhookStatus": {
3045
+ "type": [
3046
+ "string",
3047
+ "null"
3048
+ ],
3049
+ "description": "Webhook-delivery state for an inbound email. Tracks a\nSEPARATE lifecycle from the email's `status` field; the\nsame row carries both. Possible values:\n\n - `pending`: ingestion is past `pending` (the email itself\n is `accepted`) but the webhook fan-out has not yet\n started for this row.\n - `in_flight`: at least one delivery attempt is in flight.\n - `fired`: terminal success. Every active endpoint\n acknowledged the delivery (or accepted it after retries).\n - `failed`: terminal partial-failure. At least one endpoint\n exhausted its retry budget; some endpoints may still\n have succeeded.\n - `exhausted`: terminal failure. Every endpoint exhausted\n its retry budget without success.\n - `null`: no endpoints configured, so no webhook lifecycle\n applies.\n\nNote that the value `pending` here does NOT mean the email\nis `pending`; it means the email is past ingestion but\nwebhook delivery has not yet begun. Two overlapping uses\nof the word `pending` for distinct lifecycle phases.\n",
3050
+ "enum": [
3051
+ "pending",
3052
+ "in_flight",
3053
+ "fired",
3054
+ "failed",
3055
+ "exhausted",
3056
+ null
3057
+ ]
3058
+ },
3012
3059
  "SentEmailStatus": {
3013
3060
  "type": "string",
3014
3061
  "description": "Lifecycle status of a sent_emails row. Possible values:\n\n - `queued`: pre-call INSERT; the outbound agent has not\n yet replied.\n - `submitted_to_agent`: agent accepted; `queue_id` is set.\n - `agent_failed`: agent rejected; `error_code` and\n `error_message` carry the reason.\n - `gate_denied`: a recipient-scope gate denied the send;\n the agent was never called. The `gates` array carries\n the denial detail. /send-mail returns 403 in this case\n so callers see the denial synchronously; /sent-emails\n additionally records the row for historical lookup,\n which is when this status appears in a listing.\n - `unknown`: terminal indeterminate; the on-box log\n poller couldn't classify the receiver's response.\n - `delivered` / `bounced` / `deferred` / `wait_timeout`:\n terminal delivery outcomes (see DeliveryStatus).\n",
@@ -3026,6 +3073,7 @@ export const openapiDocument = {
3026
3073
  },
3027
3074
  "DeliveryStatus": {
3028
3075
  "type": "string",
3076
+ "description": "Narrower enum covering only the four terminal delivery\noutcomes returned to a synchronous `wait: true` send.\n\nOn the SendMailResult shape, `delivery_status` is always\nequal to `status` whenever both are present (i.e. on\nterminal-state replays and live wait=true responses).\nThe two fields exist so callers that want to type-narrow\non \"this is a delivery outcome\" can pattern-match against\nthe four-value enum without handling the broader\nSentEmailStatus value set (which also covers `queued`,\n`submitted_to_agent`, `agent_failed`, `gate_denied`,\n`unknown`).\n\nOn async-mode and pre-terminal responses, `delivery_status`\nis absent and only `status` is populated. Use `status` if\nyou want a single field that's always present.\n",
3029
3077
  "enum": [
3030
3078
  "delivered",
3031
3079
  "bounced",
@@ -3288,7 +3336,7 @@ export const openapiDocument = {
3288
3336
  "string",
3289
3337
  "null"
3290
3338
  ],
3291
- "description": "Message identifier assigned by Primitive's outbound relay, when available."
3339
+ "description": "Message identifier assigned by Primitive's OUTBOUND relay\n(the box that signs your mail and submits it to the\nreceiving MTA). NOT the receiver's queue id.\n\nThe receiver may also report its own queue id in\n`smtp_response_text` (e.g. `\"250 2.0.0 Ok: queued as\n99D111927CDA\"` from a Postfix receiver). Those two ids\nrefer to different mail systems and are NOT comparable.\nTreat `queue_id` as Primitive-internal and the\nreceiver's id as remote-system-internal.\n\nNull on rows that never reached the relay (queued,\ngate_denied, agent_failed before signing).\n"
3292
3340
  },
3293
3341
  "accepted": {
3294
3342
  "type": "array",
@@ -3870,6 +3918,23 @@ export const openapiDocument = {
3870
3918
  "delivered",
3871
3919
  "failed"
3872
3920
  ]
3921
+ },
3922
+ "DiscardContentResult": {
3923
+ "type": "object",
3924
+ "properties": {
3925
+ "discarded": {
3926
+ "type": "boolean",
3927
+ "description": "Always `true` on a 2xx response. The content is either now\ndiscarded as a result of this call, or was already discarded\nbefore this call ran.\n"
3928
+ },
3929
+ "already_discarded": {
3930
+ "type": "boolean",
3931
+ "description": "`true` if the email's content was already discarded before\nthis call ran (no work was done). `false` if this call was\nthe one that performed the discard.\n"
3932
+ }
3933
+ },
3934
+ "required": [
3935
+ "discarded",
3936
+ "already_discarded"
3937
+ ]
3873
3938
  }
3874
3939
  }
3875
3940
  }