@pidgeon-ai/sdk 1.2.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,140 @@ 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
+
76
+ ## 1.3.0 — a day of API that never reached a registry
77
+
78
+ **Additive, and one fix that was a blocker.**
79
+
80
+ 1.2.0 went out on 14 October and then the contract moved for a day without
81
+ this number moving with it. A field test on 15 October installed the published
82
+ package, called a search the live API had been accepting since the morning,
83
+ and got a `TypeError` for an argument the contract no longer requires. It
84
+ built what it needed with a hand-written HTTP client instead. Everything below
85
+ had already shipped; none of it was installable.
86
+
87
+ **Fixed.** `verifyWebhook` reads the signature headers whatever case the
88
+ framework spells them in. The lookup was `headers[name] ?? headers[name
89
+ .toLowerCase()]` and `name` is already a lowercase constant — so it read the
90
+ same key twice, and a plain object keyed `X-Pidgeon-Signature` was rejected as
91
+ unsigned. Every existing test passed, because they all hand it a `Headers` or
92
+ a lowercase object and both are already normalised. The broken branch is
93
+ reached by an object somebody built themselves, which is what API Gateway
94
+ hands a Lambda. `pidgeon` on PyPI was always right about this.
95
+
96
+ **Added.**
97
+
98
+ - `meGet`. What a key is, what it may do, and how many addresses it reaches —
99
+ the first call a credential can make about itself.
100
+ - `eventsStream`'s endpoint is in the document. The client does not wrap SSE,
101
+ because a generated client has nowhere to put a long-lived connection; the
102
+ path, the cursor header and the event shape are typed so hand-rolling it is
103
+ twenty lines rather than a reverse-engineering exercise.
104
+ - `thread.created` is emitted and subscribable. It was declared, accepted by
105
+ `POST /webhooks`, and had never once fired.
106
+ - `from_self` on `message.received`. A workspace with several addresses sees
107
+ its own outbound mail arrive at the others, genuinely inbound, with nothing
108
+ to distinguish it from a stranger — so a handler written the obvious way
109
+ answers its own reply. Check it before you reply.
110
+ - Quota pacing on a refusal: `Retry-After` and `X-RateLimit-*-Day`, so a
111
+ client refused a send can compute when it may try again instead of guessing
112
+ a day.
113
+
114
+ **Declared, having always been returned.** Three fields the API sends and the
115
+ document did not mention, so the generated types dropped them and a caller had
116
+ to cast to reach them:
117
+
118
+ - `sync_cursor` on a folder listing. `GET /mailboxes/changes` documents
119
+ `since` as "the `sync_cursor` from a listing" and no listing declared one,
120
+ so the change feed was unreachable from a typed client without defeating
121
+ its own types.
122
+ - `folder` on a search hit, and `identity` beside `identity_id` — the address
123
+ rather than the uuid, which is what the rest of the API already carries on a
124
+ thread row, on an event and on `GET /messages/{id}`. A search is the one
125
+ endpoint that spans mailboxes, so it was the one that most needed to name
126
+ them and the only one that would not. A hit is now its own type,
127
+ `SearchHit`, rather than a `MessageListItem` with two fields missing.
128
+ - `colour` is the eight-value enum it has always been enforced as, not
129
+ `string`. The palette existed only in the refusal, so the first call
130
+ anybody writes fails — somebody who has written CSS passes a hex. The
131
+ refusal was already good and is kept word for word.
132
+
133
+ **Widened.** `messagesSearch` no longer requires `identity_id`. It searches
134
+ every address the credential reaches, which is what it has always done on the
135
+ server and what `skill.md` has always said; the document said "one mailbox at
136
+ a time" in prose while declaring the parameter optional, and the generator
137
+ followed the prose. `MessageListItem` carries `identity_id`, `to`,
138
+ `counterparty` and `counterparty_name`, so a hit can be attributed to a
139
+ mailbox without a second call.
140
+
141
+ **Also.** `identitiesCreate` on the shared domain, a 404 that stopped
142
+ pretending an address it had just listed did not exist, and `repository`
143
+ fields that resolve — all four packages pointed at a private repository that
144
+ 404s for everybody who installs one.
145
+
12
146
  ## 1.2.0 — the document says what its fields mean
13
147
 
14
148
  **Additive, and a correction.**
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PCN Labs OÜ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.cjs CHANGED
@@ -62,6 +62,7 @@ __export(index_exports, {
62
62
  draftsUpdate: () => draftsUpdate,
63
63
  eventsGet: () => eventsGet,
64
64
  eventsList: () => eventsList,
65
+ eventsStream: () => eventsStream,
65
66
  identitiesAccessCreate: () => identitiesAccessCreate,
66
67
  identitiesAccessDelete: () => identitiesAccessDelete,
67
68
  identitiesAccessGet: () => identitiesAccessGet,
@@ -90,6 +91,7 @@ __export(index_exports, {
90
91
  mailboxesEmptyTrash: () => mailboxesEmptyTrash,
91
92
  mailboxesList: () => mailboxesList,
92
93
  mailboxesListFolder: () => mailboxesListFolder,
94
+ meGet: () => meGet,
93
95
  messagesAct: () => messagesAct,
94
96
  messagesForward: () => messagesForward,
95
97
  messagesGet: () => messagesGet,
@@ -107,6 +109,7 @@ __export(index_exports, {
107
109
  routingDelete: () => routingDelete,
108
110
  routingList: () => routingList,
109
111
  routingUpdate: () => routingUpdate,
112
+ streamEvents: () => streamEvents,
110
113
  threadsGet: () => threadsGet,
111
114
  threadsLabelsCreate: () => threadsLabelsCreate,
112
115
  threadsLabelsDelete: () => threadsLabelsDelete,
@@ -173,7 +176,7 @@ function createSseClient({
173
176
  ...options
174
177
  }) {
175
178
  let lastEventId;
176
- const sleep = sseSleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
179
+ const sleep2 = sseSleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
177
180
  const createStream = async function* () {
178
181
  let retryDelay = sseDefaultRetryDelay ?? 3e3;
179
182
  let attempt = 0;
@@ -277,7 +280,7 @@ function createSseClient({
277
280
  break;
278
281
  }
279
282
  const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 3e4);
280
- await sleep(backoff);
283
+ await sleep2(backoff);
281
284
  }
282
285
  }
283
286
  };
@@ -1117,7 +1120,7 @@ var messagesSend = (options) => (options.client ?? client).post({
1117
1120
  ...options.headers
1118
1121
  }
1119
1122
  });
1120
- var messagesSearch = (options) => (options.client ?? client).get({
1123
+ var messagesSearch = (options) => (options?.client ?? client).get({
1121
1124
  security: [{ scheme: "bearer", type: "http" }],
1122
1125
  url: "/messages/search",
1123
1126
  ...options
@@ -1353,6 +1356,11 @@ var appPasswordsRevoke = (options) => (options.client ?? client).delete({
1353
1356
  url: "/app-passwords/{id}",
1354
1357
  ...options
1355
1358
  });
1359
+ var meGet = (options) => (options?.client ?? client).get({
1360
+ security: [{ scheme: "bearer", type: "http" }],
1361
+ url: "/me",
1362
+ ...options
1363
+ });
1356
1364
  var workspaceGet = (options) => (options?.client ?? client).get({
1357
1365
  security: [{ scheme: "bearer", type: "http" }],
1358
1366
  url: "/workspace",
@@ -1368,6 +1376,11 @@ var eventsList = (options) => (options?.client ?? client).get({
1368
1376
  url: "/events",
1369
1377
  ...options
1370
1378
  });
1379
+ var eventsStream = (options) => (options?.client ?? client).sse.get({
1380
+ security: [{ scheme: "bearer", type: "http" }],
1381
+ url: "/events/stream",
1382
+ ...options
1383
+ });
1371
1384
  var eventsGet = (options) => (options.client ?? client).get({
1372
1385
  security: [{ scheme: "bearer", type: "http" }],
1373
1386
  url: "/events/{id}",
@@ -1589,7 +1602,16 @@ var attachmentsDownloadUrl = (options) => (options.client ?? client).get({
1589
1602
  var SIGNATURE_HEADER = "x-pidgeon-signature";
1590
1603
  var TIMESTAMP_HEADER = "x-pidgeon-timestamp";
1591
1604
  async function verifyWebhook(input) {
1592
- const get = (name) => input.headers instanceof Headers ? input.headers.get(name) ?? void 0 : input.headers[name] ?? input.headers[name.toLowerCase()];
1605
+ const get = (name) => {
1606
+ if (input.headers instanceof Headers) return input.headers.get(name) ?? void 0;
1607
+ const direct = input.headers[name];
1608
+ if (direct !== void 0) return direct;
1609
+ const wanted = name.toLowerCase();
1610
+ for (const [key2, value] of Object.entries(input.headers)) {
1611
+ if (key2.toLowerCase() === wanted) return value;
1612
+ }
1613
+ return void 0;
1614
+ };
1593
1615
  const timestamp = get(TIMESTAMP_HEADER);
1594
1616
  const signature = get(SIGNATURE_HEADER);
1595
1617
  if (!timestamp || !signature) throw new Error("This request is not signed.");
@@ -1626,6 +1648,105 @@ function constantTimeEqual(a, b) {
1626
1648
  }
1627
1649
  return difference === 0;
1628
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
+ }
1629
1750
  // Annotate the CommonJS export names for ESM import in node:
1630
1751
  0 && (module.exports = {
1631
1752
  SIGNATURE_HEADER,
@@ -1670,6 +1791,7 @@ function constantTimeEqual(a, b) {
1670
1791
  draftsUpdate,
1671
1792
  eventsGet,
1672
1793
  eventsList,
1794
+ eventsStream,
1673
1795
  identitiesAccessCreate,
1674
1796
  identitiesAccessDelete,
1675
1797
  identitiesAccessGet,
@@ -1698,6 +1820,7 @@ function constantTimeEqual(a, b) {
1698
1820
  mailboxesEmptyTrash,
1699
1821
  mailboxesList,
1700
1822
  mailboxesListFolder,
1823
+ meGet,
1701
1824
  messagesAct,
1702
1825
  messagesForward,
1703
1826
  messagesGet,
@@ -1715,6 +1838,7 @@ function constantTimeEqual(a, b) {
1715
1838
  routingDelete,
1716
1839
  routingList,
1717
1840
  routingUpdate,
1841
+ streamEvents,
1718
1842
  threadsGet,
1719
1843
  threadsLabelsCreate,
1720
1844
  threadsLabelsDelete,