@pidgeon-ai/sdk 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,70 @@ call shapes, the result shape — not to the API behind it.
9
9
 
10
10
  ## Unreleased
11
11
 
12
+ ## 1.4.0 — things the prose promised and the types did not have
13
+
14
+ **Additive, and two corrections to descriptions that were wrong.**
15
+
16
+ The rest of what a field test found on 15 October building a shared mailroom.
17
+ Everything below was either returned and undeclared, or described in the
18
+ documentation and absent from the product.
19
+
20
+ **Added to `Message`.**
21
+
22
+ - `headers`, which `skill.md` and the MCP `get_message` tool have both
23
+ promised for months — "its body **and its headers**" — against a schema
24
+ with no such property. It is a subset and the description says which:
25
+ `message-id`, `in-reply-to`, `references`, `date`, `reply-to`,
26
+ `return-path`, the four list and auto-reply headers, `x-mailer` and
27
+ `x-priority`. The whole envelope is `GET /messages/{id}/raw`. The
28
+ threading three are new to the kept set, because _will my reply land in
29
+ the right conversation_ is the question people open this for and nothing
30
+ answered it for mail between two Pidgeon addresses.
31
+ - `authentication` — `spf`, `dkim`, `dmarc`. `/docs/rules` has said since it
32
+ was written that if you need to be certain who sent something you should
33
+ _"read the authentication results on the message rather than its `From:`"_.
34
+ There were none in the contract: no such field anywhere. The columns have
35
+ been written since inbound mail existed. **Null means not reported, not
36
+ failed** — rendering an absent verdict as a failed one is worse than
37
+ showing nothing.
38
+ - `delivered_to` is the envelope recipient on every message. It was null
39
+ unless routing had moved the mail, while `message.received` carried it
40
+ every time, so the event and the read disagreed about the same message.
41
+
42
+ **Added to `Thread`.** `folder`, `from`, `from_name`, `preview` and
43
+ `has_attachments`, from the newest message. `GET /threads` is the only
44
+ workspace-wide listing and a row could say which conversations exist without
45
+ being able to draw one — so a merged inbox had to come from
46
+ `GET /mailboxes/{folder}`, which takes one address and one folder. Three
47
+ addresses and four folders is twelve calls for one screen, against a free
48
+ plan's 120 requests a minute. It is one call now.
49
+
50
+ **Added to `MailboxChange`.** `identity_id`, which was null on every row of
51
+ the only endpoint whose first required parameter is the mailbox.
52
+
53
+ **New error codes.** `blocked` and `not_permitted`, both `403`.
54
+ `/docs/rules`: _"a refusal tells you which kind it was… an agent debugging a
55
+ send needs to know which."_ Both answered `forbidden` and neither word
56
+ appeared in the response, so the only way to tell was to match the sentence.
57
+ The fix is different for each — remove a rule, or change the send policy —
58
+ which is the whole reason to separate them.
59
+
60
+ **Corrected.** Two descriptions that described something the API does not do:
61
+
62
+ - `rejected` on a send is **what the provider would not accept at hand-off**,
63
+ not a recipient your own rules forbid. That refuses the whole send with
64
+ `403`, and nothing goes to anybody. The old sentence told callers to handle
65
+ a partial send that never happens, and not to expect the exception they
66
+ actually get.
67
+ - `is_read` on `PATCH /messages/{id}`, and `read`/`unread` on
68
+ `POST /messages/actions`, are declared refused for an API key. They always
69
+ were — read state belongs to a person — but three surfaces advertised them
70
+ with no hint, including the MCP tool, whose description argued for a
71
+ capability it does not have to the one consumer that cannot check first.
72
+
73
+ **Widened.** `colour` on a label now has to be one of the eight; the
74
+ refusal's sentence is unchanged.
75
+
12
76
  ## 1.3.0 — a day of API that never reached a registry
13
77
 
14
78
  **Additive, and one fix that was a blocker.**
package/dist/index.cjs CHANGED
@@ -109,6 +109,7 @@ __export(index_exports, {
109
109
  routingDelete: () => routingDelete,
110
110
  routingList: () => routingList,
111
111
  routingUpdate: () => routingUpdate,
112
+ streamEvents: () => streamEvents,
112
113
  threadsGet: () => threadsGet,
113
114
  threadsLabelsCreate: () => threadsLabelsCreate,
114
115
  threadsLabelsDelete: () => threadsLabelsDelete,
@@ -175,7 +176,7 @@ function createSseClient({
175
176
  ...options
176
177
  }) {
177
178
  let lastEventId;
178
- const sleep = sseSleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
179
+ const sleep2 = sseSleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
179
180
  const createStream = async function* () {
180
181
  let retryDelay = sseDefaultRetryDelay ?? 3e3;
181
182
  let attempt = 0;
@@ -279,7 +280,7 @@ function createSseClient({
279
280
  break;
280
281
  }
281
282
  const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 3e4);
282
- await sleep(backoff);
283
+ await sleep2(backoff);
283
284
  }
284
285
  }
285
286
  };
@@ -1647,6 +1648,105 @@ function constantTimeEqual(a, b) {
1647
1648
  }
1648
1649
  return difference === 0;
1649
1650
  }
1651
+
1652
+ // src/stream.ts
1653
+ async function* streamEvents(options) {
1654
+ const base = (options.baseUrl ?? "https://pidgeon.ai/api/v1").replace(/\/$/, "");
1655
+ let lastEventId;
1656
+ let backoffMs = 1e3;
1657
+ while (!options.signal?.aborted) {
1658
+ const url = new URL(`${base}/events/stream`);
1659
+ if (options.identityId) url.searchParams.set("identity_id", options.identityId);
1660
+ if (options.type) url.searchParams.set("type", options.type);
1661
+ if (options.after && !lastEventId) url.searchParams.set("after", options.after);
1662
+ let response;
1663
+ try {
1664
+ response = await fetch(url, {
1665
+ headers: {
1666
+ authorization: `Bearer ${options.apiKey}`,
1667
+ accept: "text/event-stream",
1668
+ ...lastEventId ? { "last-event-id": lastEventId } : {}
1669
+ },
1670
+ signal: options.signal
1671
+ });
1672
+ } catch {
1673
+ if (options.signal?.aborted) return;
1674
+ await sleep(backoffMs, options.signal);
1675
+ backoffMs = Math.min(backoffMs * 2, 3e4);
1676
+ continue;
1677
+ }
1678
+ if (response.status === 401 || response.status === 403 || response.status === 404) {
1679
+ throw new Error(
1680
+ `Pidgeon refused the stream: ${response.status} ${await response.text().catch(() => "")}`.trim()
1681
+ );
1682
+ }
1683
+ if (!response.ok || !response.body) {
1684
+ await sleep(backoffMs, options.signal);
1685
+ backoffMs = Math.min(backoffMs * 2, 3e4);
1686
+ continue;
1687
+ }
1688
+ backoffMs = 1e3;
1689
+ for await (const frame of frames(response.body, options.signal)) {
1690
+ if (frame.id) lastEventId = frame.id;
1691
+ if (!frame.data) continue;
1692
+ if (frame.event === "error") continue;
1693
+ let parsed;
1694
+ try {
1695
+ parsed = JSON.parse(frame.data);
1696
+ } catch {
1697
+ continue;
1698
+ }
1699
+ yield parsed;
1700
+ }
1701
+ }
1702
+ }
1703
+ async function* frames(body, signal) {
1704
+ const reader = body.getReader();
1705
+ const decoder = new TextDecoder();
1706
+ let buffer = "";
1707
+ try {
1708
+ while (!signal?.aborted) {
1709
+ const { done, value } = await reader.read();
1710
+ if (done) return;
1711
+ buffer += decoder.decode(value, { stream: true });
1712
+ let boundary = buffer.indexOf("\n\n");
1713
+ while (boundary !== -1) {
1714
+ const block = buffer.slice(0, boundary);
1715
+ buffer = buffer.slice(boundary + 2);
1716
+ boundary = buffer.indexOf("\n\n");
1717
+ const frame = {};
1718
+ const data = [];
1719
+ for (const line of block.split("\n")) {
1720
+ if (line.startsWith(":")) continue;
1721
+ const colon = line.indexOf(":");
1722
+ if (colon === -1) continue;
1723
+ const field = line.slice(0, colon);
1724
+ const value2 = line.slice(colon + 1).replace(/^ /, "");
1725
+ if (field === "id") frame.id = value2;
1726
+ else if (field === "event") frame.event = value2;
1727
+ else if (field === "data") data.push(value2);
1728
+ }
1729
+ if (data.length > 0) frame.data = data.join("\n");
1730
+ if (frame.id || frame.data) yield frame;
1731
+ }
1732
+ }
1733
+ } finally {
1734
+ await reader.cancel().catch(() => void 0);
1735
+ }
1736
+ }
1737
+ function sleep(ms, signal) {
1738
+ return new Promise((resolve) => {
1739
+ const timer = setTimeout(resolve, ms);
1740
+ signal?.addEventListener(
1741
+ "abort",
1742
+ () => {
1743
+ clearTimeout(timer);
1744
+ resolve();
1745
+ },
1746
+ { once: true }
1747
+ );
1748
+ });
1749
+ }
1650
1750
  // Annotate the CommonJS export names for ESM import in node:
1651
1751
  0 && (module.exports = {
1652
1752
  SIGNATURE_HEADER,
@@ -1738,6 +1838,7 @@ function constantTimeEqual(a, b) {
1738
1838
  routingDelete,
1739
1839
  routingList,
1740
1840
  routingUpdate,
1841
+ streamEvents,
1741
1842
  threadsGet,
1742
1843
  threadsLabelsCreate,
1743
1844
  threadsLabelsDelete,