@nimbus-dev/connectors 0.2.1 → 0.2.3

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.
Files changed (220) hide show
  1. package/README.md +19 -7
  2. package/connectors/airflow/src/search-filter.ts +2 -15
  3. package/connectors/airflow/src/server.ts +2 -87
  4. package/connectors/airflow/src/tools.ts +74 -0
  5. package/connectors/apple/src/adapters.ts +326 -0
  6. package/connectors/apple/src/apple-mail-core.ts +4 -8
  7. package/connectors/apple/src/calendar-tools.ts +28 -8
  8. package/connectors/apple/src/server.ts +23 -512
  9. package/connectors/apple/src/tools.ts +18 -44
  10. package/connectors/argocd/src/server.ts +25 -38
  11. package/connectors/athena/src/tools.ts +26 -43
  12. package/connectors/aws/src/server.ts +2 -183
  13. package/connectors/aws/src/tools.ts +199 -0
  14. package/connectors/azure/src/server.ts +2 -131
  15. package/connectors/azure/src/tools.ts +134 -0
  16. package/connectors/bigeye/src/search-filter.ts +4 -15
  17. package/connectors/bigeye/src/server.ts +38 -38
  18. package/connectors/bigquery/src/tools.ts +1 -26
  19. package/connectors/bitbucket/src/server.ts +2 -244
  20. package/connectors/bitbucket/src/tools.ts +241 -0
  21. package/connectors/bitrise/src/server.ts +2 -79
  22. package/connectors/bitrise/src/tools.ts +76 -0
  23. package/connectors/canva/src/search-filter.ts +2 -11
  24. package/connectors/canva/src/server.ts +2 -73
  25. package/connectors/canva/src/tools.ts +66 -0
  26. package/connectors/circleci/src/server.ts +2 -176
  27. package/connectors/circleci/src/tools.ts +193 -0
  28. package/connectors/cloud-logging/src/tools.ts +19 -35
  29. package/connectors/cloudwatch/src/tools.ts +20 -38
  30. package/connectors/codemagic/src/server.ts +2 -70
  31. package/connectors/codemagic/src/tools.ts +71 -0
  32. package/connectors/confluence/src/server.ts +2 -301
  33. package/connectors/confluence/src/tools.ts +320 -0
  34. package/connectors/dagster/src/server.ts +2 -214
  35. package/connectors/dagster/src/tools.ts +187 -0
  36. package/connectors/databricks/src/search-filter.ts +7 -7
  37. package/connectors/databricks/src/server.ts +2 -77
  38. package/connectors/databricks/src/tools.ts +66 -0
  39. package/connectors/datadog/src/server.ts +2 -48
  40. package/connectors/datadog/src/tools.ts +48 -0
  41. package/connectors/dataprofile/src/profile.ts +16 -46
  42. package/connectors/dbt/src/server.ts +2 -92
  43. package/connectors/dbt/src/tools.ts +80 -0
  44. package/connectors/dependencytrack/src/server.ts +2 -76
  45. package/connectors/dependencytrack/src/tools.ts +65 -0
  46. package/connectors/discord/src/server.ts +2 -85
  47. package/connectors/discord/src/tools.ts +82 -0
  48. package/connectors/elasticsearch/src/tools.ts +7 -37
  49. package/connectors/fastmail/src/jmap-client.ts +202 -0
  50. package/connectors/fastmail/src/jmap-core.ts +8 -21
  51. package/connectors/fastmail/src/server.ts +2 -187
  52. package/connectors/fastmail/src/tools.ts +4 -23
  53. package/connectors/figma/src/search-filter.ts +2 -11
  54. package/connectors/figma/src/server.ts +2 -113
  55. package/connectors/figma/src/tools.ts +99 -0
  56. package/connectors/firebase/src/server.ts +2 -90
  57. package/connectors/firebase/src/token.ts +2 -5
  58. package/connectors/firebase/src/tools.ts +106 -0
  59. package/connectors/flagsmith/src/server.ts +2 -98
  60. package/connectors/flagsmith/src/tools.ts +95 -0
  61. package/connectors/flux/src/server.ts +52 -53
  62. package/connectors/gcp/src/server.ts +2 -132
  63. package/connectors/gcp/src/tools.ts +123 -0
  64. package/connectors/github/src/server.ts +2 -328
  65. package/connectors/github/src/tools.ts +311 -0
  66. package/connectors/github-actions/src/server.ts +2 -205
  67. package/connectors/github-actions/src/tools.ts +209 -0
  68. package/connectors/gitlab/src/server.ts +2 -384
  69. package/connectors/gitlab/src/tools.ts +392 -0
  70. package/connectors/gmail/src/server.ts +2 -270
  71. package/connectors/gmail/src/tools.ts +209 -0
  72. package/connectors/google-drive/src/server.ts +2 -524
  73. package/connectors/google-drive/src/tools.ts +529 -0
  74. package/connectors/google-meet/src/server.ts +2 -82
  75. package/connectors/google-meet/src/tools.ts +87 -0
  76. package/connectors/google-photos/src/server.ts +2 -132
  77. package/connectors/google-photos/src/tools.ts +138 -0
  78. package/connectors/grafana/src/server.ts +2 -53
  79. package/connectors/grafana/src/tools.ts +53 -0
  80. package/connectors/great-expectations/src/gx-parse.ts +14 -45
  81. package/connectors/greenhouse/src/server.ts +2 -63
  82. package/connectors/greenhouse/src/tools.ts +68 -0
  83. package/connectors/hubspot/src/server.ts +2 -77
  84. package/connectors/hubspot/src/tools.ts +70 -0
  85. package/connectors/iac/src/server.ts +2 -137
  86. package/connectors/iac/src/tools.ts +153 -0
  87. package/connectors/imap/src/imap-core.ts +29 -82
  88. package/connectors/imap/src/server.ts +33 -220
  89. package/connectors/intercom/src/server.ts +2 -61
  90. package/connectors/intercom/src/tools.ts +58 -0
  91. package/connectors/jenkins/src/jenkins-api.ts +2 -6
  92. package/connectors/jenkins/src/server.ts +2 -215
  93. package/connectors/jenkins/src/tools.ts +240 -0
  94. package/connectors/jira/src/server.ts +2 -301
  95. package/connectors/jira/src/tools.ts +325 -0
  96. package/connectors/kubernetes/src/server.ts +2 -155
  97. package/connectors/kubernetes/src/tools.ts +160 -0
  98. package/connectors/launchdarkly/src/server.ts +2 -75
  99. package/connectors/launchdarkly/src/tools.ts +72 -0
  100. package/connectors/lever/src/search-filter.ts +4 -12
  101. package/connectors/lever/src/server.ts +2 -60
  102. package/connectors/lever/src/tools.ts +61 -0
  103. package/connectors/linear/src/server.ts +2 -459
  104. package/connectors/linear/src/tools.ts +482 -0
  105. package/connectors/localdb/src/sql-scan.ts +13 -41
  106. package/connectors/localdb/src/tools.ts +1 -1
  107. package/connectors/looker/src/search-filter.ts +2 -15
  108. package/connectors/looker/src/server.ts +35 -70
  109. package/connectors/mendeley/src/server.ts +2 -55
  110. package/connectors/mendeley/src/tools.ts +54 -0
  111. package/connectors/mercury/src/server.ts +2 -57
  112. package/connectors/mercury/src/tools.ts +50 -0
  113. package/connectors/metabase/src/search-filter.ts +4 -15
  114. package/connectors/metabase/src/server.ts +2 -79
  115. package/connectors/metabase/src/tools.ts +64 -0
  116. package/connectors/miro/src/server.ts +2 -72
  117. package/connectors/miro/src/tools.ts +65 -0
  118. package/connectors/mlflow/src/search-filter.ts +3 -7
  119. package/connectors/mlflow/src/server.ts +20 -40
  120. package/connectors/monte-carlo/src/server.ts +50 -57
  121. package/connectors/netlify/src/search-filter.ts +6 -14
  122. package/connectors/netlify/src/server.ts +2 -60
  123. package/connectors/netlify/src/tools.ts +53 -0
  124. package/connectors/newrelic/src/server.ts +2 -40
  125. package/connectors/newrelic/src/tools.ts +37 -0
  126. package/connectors/notion/src/server.ts +2 -335
  127. package/connectors/notion/src/tools.ts +344 -0
  128. package/connectors/obsidian/src/server.ts +2 -425
  129. package/connectors/obsidian/src/tools.ts +466 -0
  130. package/connectors/onedrive/src/server.ts +2 -249
  131. package/connectors/onedrive/src/tools.ts +253 -0
  132. package/connectors/outlook/src/server.ts +2 -351
  133. package/connectors/outlook/src/tools.ts +357 -0
  134. package/connectors/pagerduty/src/server.ts +2 -132
  135. package/connectors/pagerduty/src/tools.ts +126 -0
  136. package/connectors/pipedrive/src/server.ts +2 -58
  137. package/connectors/pipedrive/src/tools.ts +63 -0
  138. package/connectors/powerbi/src/search-filter.ts +2 -15
  139. package/connectors/powerbi/src/server.ts +43 -68
  140. package/connectors/prefect/src/search-filter.ts +3 -16
  141. package/connectors/prefect/src/server.ts +2 -90
  142. package/connectors/prefect/src/tools.ts +83 -0
  143. package/connectors/protonmail/src/mail-core.ts +16 -75
  144. package/connectors/protonmail/src/server.ts +34 -214
  145. package/connectors/raindrop/src/server.ts +2 -103
  146. package/connectors/raindrop/src/tools.ts +103 -0
  147. package/connectors/ramp/src/server.ts +2 -105
  148. package/connectors/ramp/src/tools.ts +98 -0
  149. package/connectors/readwise/src/server.ts +2 -88
  150. package/connectors/readwise/src/tools.ts +81 -0
  151. package/connectors/sagemaker/src/tools.ts +21 -40
  152. package/connectors/salesforce/src/search-filter.ts +4 -11
  153. package/connectors/salesforce/src/server.ts +2 -90
  154. package/connectors/salesforce/src/tools.ts +75 -0
  155. package/connectors/semgrep/src/search-filter.ts +3 -10
  156. package/connectors/semgrep/src/server.ts +2 -104
  157. package/connectors/semgrep/src/tools.ts +92 -0
  158. package/connectors/sentry/src/server.ts +2 -58
  159. package/connectors/sentry/src/tools.ts +53 -0
  160. package/connectors/slack/src/server.ts +2 -352
  161. package/connectors/slack/src/tools.ts +376 -0
  162. package/connectors/snowflake/src/search-filter.ts +4 -19
  163. package/connectors/snowflake/src/server.ts +6 -25
  164. package/connectors/snyk/src/server.ts +2 -140
  165. package/connectors/snyk/src/tools.ts +136 -0
  166. package/connectors/sonarqube/src/server.ts +2 -111
  167. package/connectors/sonarqube/src/tools.ts +98 -0
  168. package/connectors/stackoverflow/src/server.ts +2 -77
  169. package/connectors/stackoverflow/src/tools.ts +66 -0
  170. package/connectors/storybook/src/storybook-parse.ts +3 -6
  171. package/connectors/stripe/src/server.ts +2 -61
  172. package/connectors/stripe/src/tools.ts +54 -0
  173. package/connectors/superset/src/search-filter.ts +4 -15
  174. package/connectors/superset/src/server.ts +2 -115
  175. package/connectors/superset/src/tools.ts +98 -0
  176. package/connectors/tableau/src/search-filter.ts +2 -15
  177. package/connectors/tableau/src/server.ts +13 -65
  178. package/connectors/teams/src/server.ts +2 -249
  179. package/connectors/teams/src/tools.ts +283 -0
  180. package/connectors/testflight/src/server.ts +2 -70
  181. package/connectors/testflight/src/tools.ts +79 -0
  182. package/connectors/vercel/src/search-filter.ts +2 -10
  183. package/connectors/vercel/src/server.ts +2 -75
  184. package/connectors/vercel/src/tools.ts +73 -0
  185. package/connectors/vertex-ai/src/tools.ts +23 -33
  186. package/connectors/wiz/src/search-filter.ts +6 -30
  187. package/connectors/wiz/src/server.ts +2 -188
  188. package/connectors/wiz/src/tools.ts +168 -0
  189. package/connectors/zendesk/src/server.ts +2 -73
  190. package/connectors/zendesk/src/tools.ts +60 -0
  191. package/connectors/zoom/src/search-filter.ts +1 -7
  192. package/connectors/zoom/src/server.ts +2 -94
  193. package/connectors/zoom/src/tools.ts +93 -0
  194. package/connectors/zotero/src/server.ts +2 -78
  195. package/connectors/zotero/src/tools.ts +67 -0
  196. package/package.json +14 -12
  197. package/shared/access-token-cache.ts +123 -0
  198. package/shared/atlassian-json-fetch.ts +6 -16
  199. package/shared/audit-chain.ts +601 -34
  200. package/shared/cli-json-kit.ts +191 -0
  201. package/shared/collection-tool-kit.ts +143 -0
  202. package/shared/connector-mode.ts +3 -2
  203. package/shared/consent-kit.ts +81 -26
  204. package/shared/cursor-list-tool.ts +29 -0
  205. package/shared/env-json-api.ts +209 -0
  206. package/shared/fetch-json-text.ts +27 -0
  207. package/shared/github-rest.ts +62 -0
  208. package/shared/graphql-json.ts +60 -0
  209. package/shared/imap-mail-core.ts +91 -0
  210. package/shared/imap-tool-kit.ts +57 -23
  211. package/shared/imapflow-adapter.ts +417 -0
  212. package/shared/nimbus-spawn.ts +84 -27
  213. package/shared/rest-tool-kit.ts +77 -7
  214. package/shared/run-cli-json.ts +38 -16
  215. package/shared/safe-cli-arg.ts +139 -17
  216. package/shared/search-filter.ts +58 -18
  217. package/shared/walk-files.ts +118 -0
  218. package/shared/windows-batch-args.ts +273 -0
  219. package/shared/write-budget.ts +100 -0
  220. package/standalone/src/launcher.ts +19 -7
@@ -0,0 +1,417 @@
1
+ /**
2
+ * imapflow-adapter — the real IMAP client + SMTP mailer over `imapflow` and
3
+ * `nodemailer`, shared by every IMAP-based mail connector (imap, protonmail).
4
+ *
5
+ * These two class bodies were previously written out in full in each
6
+ * connector's `server.ts`. `imap-tool-kit.ts` deliberately left them alone at
7
+ * the time ("class bodies … are out of scope per the dedup brief"), and the
8
+ * cost of that was measurable on both axes Sonar reports:
9
+ *
10
+ * - duplication: `imap/src/server.ts` 41.2% and `protonmail/src/server.ts`
11
+ * 38.8%, 186 duplicated lines between them — the largest pair in the repo;
12
+ * - coverage: both files were 0%. An entry point that constructs its adapters
13
+ * at module scope cannot be imported by a test without opening real
14
+ * sockets, so every line of transport logic in them was unasserted.
15
+ *
16
+ * Extracting the classes here fixes both: one copy, and one that a test can
17
+ * construct because the `imapflow` client and the `nodemailer` transport are
18
+ * injected seams rather than direct `new` calls. The two connectors differ only
19
+ * in configuration (TLS mode, ports, env var names, mailbox default), so all of
20
+ * it is passed in.
21
+ *
22
+ * HARD SCOPE CONSTRAINT (security), inherited from both connectors: the fetch
23
+ * query below requests ONLY `envelope` + `bodyStructure` + a `PREVIEW_FETCH_BYTES`-
24
+ * capped text part. There is no surface here to request `BODY[]` or an
25
+ * attachment part, so no caller can make this adapter download message bodies
26
+ * or attachment bytes.
27
+ */
28
+
29
+ import {
30
+ type FetchMessageObject,
31
+ type FetchQueryObject,
32
+ ImapFlow,
33
+ type MessageAddressObject,
34
+ } from "imapflow";
35
+ import { createTransport, type Transporter } from "nodemailer";
36
+ import {
37
+ type BodyStructureNode,
38
+ extractAttachments,
39
+ findTextPlainPart,
40
+ } from "./imap-bodystructure.ts";
41
+ import { clampLimit, type MailAddress, PREVIEW_FETCH_BYTES } from "./imap-mail-core.ts";
42
+ import {
43
+ type EmailMessageMeta,
44
+ type EmailReadClient,
45
+ type EmailSendMailer,
46
+ previewFromParts,
47
+ } from "./imap-tool-kit.ts";
48
+
49
+ /** Mailbox used when a tool call does not name one. */
50
+ export const DEFAULT_MAILBOX = "INBOX";
51
+
52
+ /** Connection settings for one IMAP endpoint. Read from `process.env` by the caller. */
53
+ export interface ImapEndpointConfig {
54
+ readonly host: string;
55
+ readonly port: number;
56
+ readonly user: string;
57
+ readonly pass: string;
58
+ /**
59
+ * Implicit TLS on connect. `true` for a normal IMAPS endpoint on 993; `false`
60
+ * for the ProtonMail Bridge, which speaks STARTTLS on loopback.
61
+ */
62
+ readonly secure: boolean;
63
+ /**
64
+ * Chain verification. Omit (the default) for a real endpoint. Only the
65
+ * ProtonMail Bridge passes `false`, for its self-signed loopback certificate;
66
+ * the connection never leaves 127.0.0.1.
67
+ */
68
+ readonly rejectUnauthorized?: boolean;
69
+ /** Mailbox used when a call does not name one. Defaults to {@link DEFAULT_MAILBOX}. */
70
+ readonly defaultMailbox?: string;
71
+ }
72
+
73
+ /** Connection settings for one SMTP endpoint. */
74
+ export interface SmtpEndpointConfig {
75
+ readonly host: string;
76
+ readonly port: number;
77
+ readonly user: string;
78
+ readonly pass: string;
79
+ readonly secure: boolean;
80
+ readonly rejectUnauthorized?: boolean;
81
+ }
82
+
83
+ /**
84
+ * There is deliberately no `requireTLS` option.
85
+ *
86
+ * When `secure` is false this adapter ALWAYS demands STARTTLS, and that is not
87
+ * a default a caller can override — an unencrypted SMTP session is not a
88
+ * configuration this module offers. Without it nodemailer falls back to
89
+ * plaintext when the server does not advertise STARTTLS, and the password goes
90
+ * out in the clear.
91
+ *
92
+ * The gap was live, not hypothetical: `imap` computes `secure: port === 465`,
93
+ * so an operator setting `IMAP_SMTP_PORT=587` — the standard submission port —
94
+ * got `secure: false` with no `requireTLS` at all. Of the three copies this
95
+ * adapter replaced, only Apple's set it.
96
+
97
+ /** Constructs the `imapflow` client. Overridden in tests so no socket is opened. */
98
+ export type ImapFlowFactory = (options: ConstructorParameters<typeof ImapFlow>[0]) => ImapFlow;
99
+
100
+ /**
101
+ * The transport options this adapter builds. Declared here rather than reused
102
+ * from nodemailer: `Parameters<typeof createTransport>[0]` resolves to the
103
+ * union `Transport | TransportOptions`, and a plain SMTP option literal is not
104
+ * assignable to that union.
105
+ */
106
+ export interface SmtpTransportOptions {
107
+ readonly host: string;
108
+ readonly port: number;
109
+ readonly secure: boolean;
110
+ readonly auth: { readonly user: string; readonly pass: string };
111
+ /** Always set by this module, and always `true` when `secure` is false. */
112
+ readonly requireTLS: boolean;
113
+ readonly tls?: { readonly rejectUnauthorized: boolean };
114
+ }
115
+
116
+ /** Constructs the `nodemailer` transport. Overridden in tests so no socket is opened. */
117
+ export type TransportFactory = (options: SmtpTransportOptions) => Transporter;
118
+
119
+ /**
120
+ * Build a {@link MailAddress} from an imapflow envelope address, omitting
121
+ * `name`/`address` when absent — the interface uses `exactOptionalPropertyTypes`,
122
+ * so an explicit `undefined` is not assignable.
123
+ *
124
+ * The parameter is imapflow's own `MessageAddressObject`, not a restated
125
+ * `{ name?: string; address?: string }`. imapflow 2 generates its declarations,
126
+ * which spell the fields `name?: string | undefined`, and under
127
+ * `exactOptionalPropertyTypes` a hand-written shape without the `| undefined`
128
+ * stops accepting the library's value.
129
+ */
130
+ function toMailAddress(a: MessageAddressObject): MailAddress {
131
+ const out: { name?: string; address?: string } = {};
132
+ if (a.name !== undefined) {
133
+ out.name = a.name;
134
+ }
135
+ if (a.address !== undefined) {
136
+ out.address = a.address;
137
+ }
138
+ return out;
139
+ }
140
+
141
+ function envelopeFromImap(env: FetchMessageObject["envelope"]): EmailMessageMeta["envelope"] {
142
+ if (env === undefined) {
143
+ return {};
144
+ }
145
+ return {
146
+ date: env.date ?? null,
147
+ subject: env.subject ?? null,
148
+ messageId: env.messageId ?? null,
149
+ from: (env.from ?? []).map(toMailAddress),
150
+ to: (env.to ?? []).map(toMailAddress),
151
+ cc: (env.cc ?? []).map(toMailAddress),
152
+ };
153
+ }
154
+
155
+ /** Reduce one fetched message to the header + attachment-metadata + preview view. */
156
+ export function toMessageMeta(
157
+ msg: FetchMessageObject,
158
+ mailbox: string,
159
+ uidValidity: string | null,
160
+ ): EmailMessageMeta {
161
+ const structure = (msg.bodyStructure ?? null) as BodyStructureNode | null;
162
+ const partKey = findTextPlainPart(structure);
163
+ return {
164
+ uid: msg.uid,
165
+ mailbox,
166
+ uidValidity,
167
+ envelope: envelopeFromImap(msg.envelope),
168
+ attachments: extractAttachments(structure),
169
+ preview: previewFromParts(msg.bodyParts, partKey),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Fetch query that requests ONLY headers + structure + a truncated text body
175
+ * part. There is no `source` / full-body request here by construction.
176
+ */
177
+ export function previewFetchQuery(): FetchQueryObject {
178
+ return {
179
+ uid: true,
180
+ envelope: true,
181
+ bodyStructure: true,
182
+ internalDate: true,
183
+ // Two candidate text part keys, each capped to PREVIEW_FETCH_BYTES bytes.
184
+ bodyParts: [
185
+ { key: "1", start: 0, maxLength: PREVIEW_FETCH_BYTES },
186
+ { key: "TEXT", start: 0, maxLength: PREVIEW_FETCH_BYTES },
187
+ ],
188
+ };
189
+ }
190
+
191
+ /**
192
+ * imapflow answers `undefined` from `fetchOne` and `search` when it has no
193
+ * mailbox selected — reachable under the lock `withMailbox` holds only if the
194
+ * connection closed after it was taken.
195
+ *
196
+ * imapflow 1.x did the same at runtime but declared no such return, so the
197
+ * value reached `toMessageMeta` / `.length` and failed as a bare TypeError. The
198
+ * generated declarations of 2.x surface it. It stays a FAILURE, now one that
199
+ * says what happened — not "no such message" or "no results", which would
200
+ * report a dropped connection as an answer.
201
+ */
202
+ function mailboxNotSelected(mailbox: string): Error {
203
+ return new Error(`imap: mailbox "${mailbox}" is no longer selected (connection closed?)`);
204
+ }
205
+
206
+ /**
207
+ * Real IMAP client over imapflow. Per-call connect/lock/logout keeps it
208
+ * stateless, so nothing is held open between tool calls.
209
+ */
210
+ class ImapFlowClient implements EmailReadClient {
211
+ private readonly defaultMailbox: string;
212
+
213
+ constructor(
214
+ private readonly config: ImapEndpointConfig,
215
+ private readonly newImapFlow: ImapFlowFactory,
216
+ ) {
217
+ this.defaultMailbox = config.defaultMailbox ?? DEFAULT_MAILBOX;
218
+ }
219
+
220
+ private newClient(): ImapFlow {
221
+ const { host, port, secure, user, pass, rejectUnauthorized } = this.config;
222
+ return this.newImapFlow({
223
+ host,
224
+ port,
225
+ secure,
226
+ auth: { user, pass },
227
+ logger: false,
228
+ // Only set when the caller asked to relax it — see ImapEndpointConfig.
229
+ ...(rejectUnauthorized === undefined ? {} : { tls: { rejectUnauthorized } }),
230
+ });
231
+ }
232
+
233
+ private async withMailbox<T>(
234
+ mailbox: string,
235
+ fn: (client: ImapFlow, uidValidity: string | null) => Promise<T>,
236
+ ): Promise<T> {
237
+ const client = this.newClient();
238
+ await client.connect();
239
+ // `logout` sits in an OUTER finally so the connection is closed even when
240
+ // `getMailboxLock` throws. Acquiring the lock BEFORE the try — which is how
241
+ // imap and protonmail both had it — leaks the connected socket on a locking
242
+ // failure (a mailbox that does not exist, or one another client holds), and
243
+ // this client connects per call, so every such failure leaks another.
244
+ // Apple's copy already nested it this way; the correction travelled the
245
+ // wrong direction when these were three separate copies.
246
+ try {
247
+ const lock = await client.getMailboxLock(mailbox);
248
+ try {
249
+ const mb = client.mailbox;
250
+ const uidValidity = mb === false ? null : String(mb.uidValidity);
251
+ return await fn(client, uidValidity);
252
+ } finally {
253
+ lock.release();
254
+ }
255
+ } finally {
256
+ await client.logout();
257
+ }
258
+ }
259
+
260
+ async list(options: { mailbox?: string; limit?: number }): Promise<EmailMessageMeta[]> {
261
+ const mailbox = options.mailbox ?? this.defaultMailbox;
262
+ const limit = clampLimit(options.limit);
263
+ return this.withMailbox(mailbox, async (client, uidValidity) => {
264
+ const out: EmailMessageMeta[] = [];
265
+ const status = await client.status(mailbox, { messages: true });
266
+ // `false` is a STATUS the server rejected, read as an empty mailbox. That
267
+ // is what this did under imapflow 1.x too, which returned the same `false`
268
+ // but declared only `StatusObject`, so `status.messages ?? 0` made it 0.
269
+ const total = status === false ? 0 : (status.messages ?? 0);
270
+ if (total === 0) {
271
+ return out;
272
+ }
273
+ const start = Math.max(1, total - limit + 1);
274
+ const range = `${start}:${total}`;
275
+ for await (const msg of client.fetch(range, previewFetchQuery())) {
276
+ out.push(toMessageMeta(msg, mailbox, uidValidity));
277
+ }
278
+ // Most-recent first.
279
+ out.sort((a, b) => b.uid - a.uid);
280
+ return out;
281
+ });
282
+ }
283
+
284
+ async get(uid: number, mailbox?: string): Promise<EmailMessageMeta | null> {
285
+ const box = mailbox ?? this.defaultMailbox;
286
+ return this.withMailbox(box, async (client, uidValidity) => {
287
+ const msg = await client.fetchOne(String(uid), previewFetchQuery(), { uid: true });
288
+ if (msg === undefined) {
289
+ throw mailboxNotSelected(box);
290
+ }
291
+ return msg === false ? null : toMessageMeta(msg, box, uidValidity);
292
+ });
293
+ }
294
+
295
+ async search(options: {
296
+ query: string;
297
+ mailbox?: string;
298
+ limit?: number;
299
+ }): Promise<EmailMessageMeta[]> {
300
+ const mailbox = options.mailbox ?? this.defaultMailbox;
301
+ const limit = clampLimit(options.limit);
302
+ const q = options.query;
303
+ return this.withMailbox(mailbox, async (client, uidValidity) => {
304
+ const uids = await client.search(
305
+ { or: [{ subject: q }, { from: q }, { to: q }] },
306
+ { uid: true },
307
+ );
308
+ if (uids === undefined) {
309
+ throw mailboxNotSelected(mailbox);
310
+ }
311
+ if (uids === false || uids.length === 0) {
312
+ return [];
313
+ }
314
+ const picked = uids.slice(-limit).sort((a, b) => b - a);
315
+ const out: EmailMessageMeta[] = [];
316
+ for await (const msg of client.fetch(picked, previewFetchQuery(), { uid: true })) {
317
+ out.push(toMessageMeta(msg, mailbox, uidValidity));
318
+ }
319
+ out.sort((a, b) => b.uid - a.uid);
320
+ return out;
321
+ });
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Real SMTP mailer over nodemailer.
327
+ *
328
+ * The envelope `from` is PINNED to the authenticated user and is not a `send`
329
+ * parameter, so no caller can send as someone else (the forced-sender contract
330
+ * the mail connectors document).
331
+ */
332
+ class NodemailerMailer implements EmailSendMailer {
333
+ private readonly transport: Transporter;
334
+ private readonly from: string;
335
+
336
+ constructor(config: SmtpEndpointConfig, makeTransport: TransportFactory) {
337
+ const { host, port, secure, user, pass, rejectUnauthorized } = config;
338
+ this.from = user;
339
+ this.transport = makeTransport({
340
+ host,
341
+ port,
342
+ secure,
343
+ auth: { user, pass },
344
+ // Implicit TLS already encrypts the session; otherwise STARTTLS is
345
+ // required. Not a default — there is no input to this mailer that
346
+ // produces a transport willing to send in the clear.
347
+ requireTLS: !secure,
348
+ ...(rejectUnauthorized === undefined ? {} : { tls: { rejectUnauthorized } }),
349
+ });
350
+ }
351
+
352
+ async send(input: {
353
+ to: string;
354
+ subject: string;
355
+ body: string;
356
+ cc?: string;
357
+ bcc?: string;
358
+ }): Promise<{
359
+ messageId: string | null;
360
+ accepted: readonly string[];
361
+ rejected: readonly string[];
362
+ }> {
363
+ const info = await this.transport.sendMail({
364
+ from: this.from,
365
+ to: input.to,
366
+ subject: input.subject,
367
+ text: input.body,
368
+ ...(input.cc === undefined ? {} : { cc: input.cc }),
369
+ ...(input.bcc === undefined ? {} : { bcc: input.bcc }),
370
+ });
371
+ return {
372
+ messageId: info.messageId ?? null,
373
+ accepted: (info.accepted ?? []).map(String),
374
+ rejected: (info.rejected ?? []).map(String),
375
+ };
376
+ }
377
+ }
378
+
379
+ /**
380
+ * The IMAP read client for `config`. `newImapFlow` defaults to the real
381
+ * `imapflow` constructor; tests pass a fake so no socket is opened.
382
+ */
383
+ export function createImapFlowClient(
384
+ config: ImapEndpointConfig,
385
+ newImapFlow: ImapFlowFactory = (options) => new ImapFlow(options),
386
+ ): EmailReadClient {
387
+ return new ImapFlowClient(config, newImapFlow);
388
+ }
389
+
390
+ /**
391
+ * Construct the real nodemailer transport.
392
+ *
393
+ * Written as two explicit branches rather than passing `options` straight
394
+ * through. `NodemailerMailer` has already computed the guarantee, but a
395
+ * computed value is not something a reader — or a static analyser — can check
396
+ * at the call that actually opens the socket. Here it is a literal on both
397
+ * paths: implicit TLS, or STARTTLS required. There is no third branch.
398
+ */
399
+ function realTransport(options: SmtpTransportOptions): Transporter {
400
+ const { host, port, auth } = options;
401
+ const tls = options.tls === undefined ? {} : { tls: options.tls };
402
+ if (options.secure) {
403
+ return createTransport({ host, port, auth, ...tls, secure: true });
404
+ }
405
+ return createTransport({ host, port, auth, ...tls, secure: false, requireTLS: true });
406
+ }
407
+
408
+ /**
409
+ * The SMTP mailer for `config`. `makeTransport` defaults to the real nodemailer
410
+ * transport; tests pass a fake so no socket is opened.
411
+ */
412
+ export function createNodemailerMailer(
413
+ config: SmtpEndpointConfig,
414
+ makeTransport: TransportFactory = realTransport,
415
+ ): EmailSendMailer {
416
+ return new NodemailerMailer(config, makeTransport);
417
+ }
@@ -1,7 +1,70 @@
1
- import { spawn } from "node:child_process";
1
+ /**
2
+ * nimbus-spawn — the one place this package starts a process.
3
+ *
4
+ * Every connector that reaches its service through a CLI spawns it through {@link nimbusSpawn},
5
+ * directly or by way of `run-cli-json.ts` and `cli-json-kit.ts`, and `scripts/spawn-chokepoint.test.ts`
6
+ * fails any other shipped file that imports a module able to start a process, in any import form,
7
+ * or refers to the `Bun` global at all. That is what makes a rule enforced here hold for every
8
+ * connector: today, `windows-batch-args.ts`'s refusal of an argument cmd.exe would act on, when the
9
+ * program may start a Windows batch file. Both implementations build the child's environment once
10
+ * and hand the SAME object to that check and to the spawn, so the environment that was checked is
11
+ * the one the child gets.
12
+ */
13
+
14
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
15
+ import { batchArgumentRefusal } from "./windows-batch-args.ts";
2
16
 
3
17
  export type SpawnResult = { code: number; stdout: string; stderr: string };
4
18
 
19
+ /**
20
+ * The child's environment for `env`, and why `command` must not be spawned with it, if it must
21
+ * not. Nothing is spawned for a refused command: it resolves as a failure whose stderr says so.
22
+ */
23
+ function prepareSpawn(
24
+ command: readonly string[],
25
+ env: Record<string, string | undefined>,
26
+ ): { childEnv: Record<string, string | undefined>; refusal: SpawnResult | undefined } {
27
+ const childEnv = { ...process.env, ...env };
28
+ const refusal = batchArgumentRefusal(command, childEnv);
29
+ return {
30
+ childEnv,
31
+ refusal: refusal === undefined ? undefined : { code: 1, stdout: "", stderr: refusal },
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Collect a child's output and resolve with it once the child closes or fails to start.
37
+ *
38
+ * Output is accumulated as raw `Buffer`s and decoded ONCE at the end. Decoding each chunk with
39
+ * `chunk.toString("utf8")` corrupts any multi-byte character that straddles a chunk boundary, and
40
+ * chunk boundaries are a function of pipe timing — so it fails intermittently, on non-ASCII data,
41
+ * in production.
42
+ */
43
+ function collectOutput(
44
+ child: ChildProcessWithoutNullStreams,
45
+ resolveP: (result: SpawnResult) => void,
46
+ ): void {
47
+ const outChunks: Buffer[] = [];
48
+ const errChunks: Buffer[] = [];
49
+ child.stdout.on("data", (c: Buffer) => {
50
+ outChunks.push(c);
51
+ });
52
+ child.stderr.on("data", (c: Buffer) => {
53
+ errChunks.push(c);
54
+ });
55
+ const decode = (): { stdout: string; stderr: string } => ({
56
+ stdout: Buffer.concat(outChunks).toString("utf8"),
57
+ stderr: Buffer.concat(errChunks).toString("utf8"),
58
+ });
59
+ child.on("error", (e: Error) => {
60
+ const d = decode();
61
+ resolveP({ code: 1, stdout: d.stdout, stderr: `${d.stderr}${e.message}` });
62
+ });
63
+ child.on("close", (code: number | null) => {
64
+ resolveP({ code: code ?? 1, ...decode() });
65
+ });
66
+ }
67
+
5
68
  /**
6
69
  * Node implementation. Exported so it can be tested directly: the suite runs under Bun, so
7
70
  * `nimbusSpawn` would otherwise never exercise this branch and it would ship unverified.
@@ -15,11 +78,6 @@ export type SpawnResult = { code: number; stdout: string; stderr: string };
15
78
  * `Bun.spawn` path reads stdout uncapped, and `aws logs` / `gcloud logging` JSON routinely
16
79
  * exceeds 1 MB, so `execFile` would be a silent-truncation regression dressed up as a
17
80
  * portability fix.
18
- *
19
- * Output is accumulated as raw `Buffer`s and decoded ONCE at the end. Decoding each chunk with
20
- * `chunk.toString("utf8")` corrupts any multi-byte character that straddles a chunk boundary, and
21
- * chunk boundaries are a function of pipe timing — so it fails intermittently, on non-ASCII data,
22
- * in production.
23
81
  */
24
82
  export function spawnViaNode(
25
83
  command: readonly string[],
@@ -29,27 +87,20 @@ export function spawnViaNode(
29
87
  if (bin === undefined) {
30
88
  return Promise.resolve({ code: 1, stdout: "", stderr: "empty command" });
31
89
  }
90
+ const { childEnv, refusal } = prepareSpawn(command, env);
91
+ if (refusal !== undefined) {
92
+ return Promise.resolve(refusal);
93
+ }
32
94
  return new Promise((resolveP) => {
33
- const child = spawn(bin, args, { env: { ...process.env, ...env } });
34
- const outChunks: Buffer[] = [];
35
- const errChunks: Buffer[] = [];
36
- child.stdout.on("data", (c: Buffer) => {
37
- outChunks.push(c);
38
- });
39
- child.stderr.on("data", (c: Buffer) => {
40
- errChunks.push(c);
41
- });
42
- const decode = (): { stdout: string; stderr: string } => ({
43
- stdout: Buffer.concat(outChunks).toString("utf8"),
44
- stderr: Buffer.concat(errChunks).toString("utf8"),
45
- });
46
- child.on("error", (e: Error) => {
47
- const d = decode();
48
- resolveP({ code: 1, stdout: d.stdout, stderr: `${d.stderr}${e.message}` });
49
- });
50
- child.on("close", (code: number | null) => {
51
- resolveP({ code: code ?? 1, ...decode() });
52
- });
95
+ // `spawn` THROWS, rather than emitting "error", for a command it cannot start at all: an
96
+ // argument holding a NUL byte, or — on Node, though not in Bun's `node:child_process` — a
97
+ // `.cmd` or `.bat` without a shell, which Node refuses with EINVAL. Uncaught, the throw would
98
+ // REJECT this promise, against the contract both implementations keep.
99
+ try {
100
+ collectOutput(spawn(bin, args, { env: childEnv }), resolveP);
101
+ } catch (e) {
102
+ resolveP({ code: 1, stdout: "", stderr: e instanceof Error ? e.message : String(e) });
103
+ }
53
104
  });
54
105
  }
55
106
 
@@ -61,12 +112,16 @@ export async function spawnViaBun(
61
112
  if (command.length === 0) {
62
113
  return { code: 1, stdout: "", stderr: "empty command" };
63
114
  }
115
+ const { childEnv, refusal } = prepareSpawn(command, env);
116
+ if (refusal !== undefined) {
117
+ return refusal;
118
+ }
64
119
  // `Bun.spawn` THROWS synchronously when the binary does not exist, where `child_process` emits
65
120
  // an "error" event instead. Both branches must honour the same contract — resolve with a
66
121
  // non-zero code, never reject — or a caller's behaviour would depend on the runtime.
67
122
  try {
68
123
  const proc = Bun.spawn([...command], {
69
- env: { ...process.env, ...env },
124
+ env: childEnv,
70
125
  stdout: "pipe",
71
126
  stderr: "pipe",
72
127
  });
@@ -119,6 +174,8 @@ export function selectSpawnImpl(g: unknown = globalThis): SpawnImpl {
119
174
  * routing unconditionally through Node made it spawn a real `aws` and hang the suite.
120
175
  *
121
176
  * Never rejects: a spawn failure resolves with a non-zero code, matching the previous behaviour.
177
+ * So does a REFUSED spawn — on Windows, an argument cmd.exe would act on, for a program that may
178
+ * start a batch file — whose stderr says why and that nothing was run.
122
179
  */
123
180
  export function nimbusSpawn(
124
181
  command: readonly string[],
@@ -1,18 +1,88 @@
1
1
  /**
2
- * rest-tool-kit — re-export of the shared REST fetch wrapper for Bearer-auth
3
- * connectors, now owned by `@nimbus-dev/sdk/connector-kit`.
2
+ * rest-tool-kit — the REST tool plumbing for token-auth connectors.
4
3
  *
5
- * These helpers originated here and were upstreamed to the SDK in 1.11.0. The
6
- * SDK is now the single owner; this module stays as the connector-facing import
7
- * path so the ~99 connectors keep their existing relative imports.
4
+ * `makeRestFetcher` and `makeRestToolRegistrar` originated here and were upstreamed
5
+ * to the SDK in 1.11.0. The SDK is now their single owner; this module re-exports
6
+ * them so the ~99 connectors keep their existing relative imports. Named
7
+ * re-exports, deliberately not `export *` — see the note in `mcp-tool-kit.ts`.
8
8
  *
9
- * Named re-exports, deliberately not `export *` — see the note in
10
- * `mcp-tool-kit.ts`.
9
+ * The two helpers defined BELOW are this repository's own and stay here:
10
+ * {@link makeRestWriteToolRegistrar} registers through the consent kit, which the
11
+ * SDK does not have, and {@link toRestFetchResult} is the tail of the connector
12
+ * fetches whose credential is not a Bearer token, which the SDK's fetch cannot send.
11
13
  */
12
14
 
15
+ import {
16
+ type HttpJsonBodyResponse,
17
+ mcpJsonResultIfOk,
18
+ type RestFetchResult,
19
+ requireProcessEnv,
20
+ type ZodObjectSchema,
21
+ } from "@nimbus-dev/sdk/connector-kit";
22
+ import type { WriteToolConfig, WriteToolRegistrar } from "./consent-kit.ts";
23
+
13
24
  export type {
14
25
  RestFetcherConfig,
15
26
  RestFetchResult,
16
27
  RestToolRegistrar,
17
28
  } from "@nimbus-dev/sdk/connector-kit";
18
29
  export { makeRestFetcher, makeRestToolRegistrar } from "@nimbus-dev/sdk/connector-kit";
30
+
31
+ /** The per-connector registrar {@link makeRestWriteToolRegistrar} builds. */
32
+ export type RestWriteToolRegistrar = <T>(
33
+ name: string,
34
+ cfg: WriteToolConfig<T>,
35
+ description: string,
36
+ schema: ZodObjectSchema<T>,
37
+ buildPath: (args: T) => string,
38
+ buildInit?: (args: T) => RequestInit,
39
+ ) => void;
40
+
41
+ /**
42
+ * The write twin of `makeRestToolRegistrar`: the same standard tool body —
43
+ * `token = requireProcessEnv(<env>)`, `res = await <fetch>(token, buildPath(args)
44
+ * [, buildInit(args)])`, `return mcpJsonResultIfOk(<label>, res, <snippetMax>)` —
45
+ * registered through the connector's consent-kit write registrar instead of its
46
+ * read-only one, so every mutation it registers is gated.
47
+ *
48
+ * The connector passes its OWN `registerWriteTool` in rather than this kit building
49
+ * one: the standalone launcher decides whether a connector's mutations are gated by
50
+ * reading that connector's own `server.ts`/`tools.ts`, and a registrar constructed in
51
+ * here would be invisible to it.
52
+ */
53
+ export function makeRestWriteToolRegistrar(cfg: {
54
+ readonly registerWriteTool: WriteToolRegistrar;
55
+ readonly tokenEnv: string;
56
+ readonly serviceLabel: string;
57
+ readonly fetch: (
58
+ token: string,
59
+ pathOrUrl: string,
60
+ init?: RequestInit,
61
+ ) => Promise<HttpJsonBodyResponse>;
62
+ /** Body-snippet length for the `<label> <status>: <snippet>` error (mcpJsonResultIfOk default 300). */
63
+ readonly snippetMax?: number;
64
+ }): RestWriteToolRegistrar {
65
+ return (name, toolCfg, description, schema, buildPath, buildInit) => {
66
+ cfg.registerWriteTool(name, toolCfg, description, schema, async (args) => {
67
+ const token = requireProcessEnv(cfg.tokenEnv);
68
+ const res = await cfg.fetch(token, buildPath(args), buildInit?.(args));
69
+ return mcpJsonResultIfOk(cfg.serviceLabel, res, cfg.snippetMax);
70
+ });
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Read a response into the {@link RestFetchResult} shape a REST tool's fetch
76
+ * returns. `json` is the parsed body, or `null` when the body is not JSON (an empty
77
+ * 204, an HTML error page): what that means is the tool's decision, not this one's.
78
+ */
79
+ export async function toRestFetchResult(res: Response): Promise<RestFetchResult> {
80
+ const text = await res.text();
81
+ let json: unknown;
82
+ try {
83
+ json = JSON.parse(text) as unknown;
84
+ } catch {
85
+ json = null;
86
+ }
87
+ return { ok: res.ok, status: res.status, json, text };
88
+ }