@nimbus-dev/connectors 0.2.0 → 0.2.2

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 (215) hide show
  1. package/README.md +18 -4
  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 +15 -34
  12. package/connectors/aws/src/server.ts +2 -183
  13. package/connectors/aws/src/tools.ts +197 -0
  14. package/connectors/azure/src/server.ts +2 -131
  15. package/connectors/azure/src/tools.ts +133 -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 +14 -27
  29. package/connectors/cloudwatch/src/tools.ts +15 -34
  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 +152 -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 +159 -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 +19 -38
  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 +15 -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 +13 -6
  200. package/shared/cli-json-kit.ts +150 -0
  201. package/shared/collection-tool-kit.ts +143 -0
  202. package/shared/connector-mode.ts +3 -2
  203. package/shared/cursor-list-tool.ts +29 -0
  204. package/shared/env-json-api.ts +209 -0
  205. package/shared/fetch-json-text.ts +27 -0
  206. package/shared/github-rest.ts +62 -0
  207. package/shared/graphql-json.ts +60 -0
  208. package/shared/imap-mail-core.ts +91 -0
  209. package/shared/imap-tool-kit.ts +57 -23
  210. package/shared/imapflow-adapter.ts +417 -0
  211. package/shared/rest-tool-kit.ts +77 -7
  212. package/shared/run-cli-json.ts +38 -16
  213. package/shared/search-filter.ts +58 -18
  214. package/shared/walk-files.ts +118 -0
  215. package/standalone/src/launcher.ts +19 -7
@@ -0,0 +1,60 @@
1
+ /**
2
+ * graphql-json — one GraphQL-over-HTTP request, for the connectors whose API is a
3
+ * GraphQL endpoint read as plain JSON (dagster, wiz).
4
+ *
5
+ * Both had written out the same request and the same reading of the response
6
+ * envelope: a present `errors` member fails the call even when `data` came back
7
+ * too, and an absent `data` member fails it as well. That reading is a rule about
8
+ * what a partial GraphQL answer means, and it belongs in one place.
9
+ *
10
+ * Linear and Monte Carlo read the envelope differently (an `errors` ARRAY that
11
+ * must be non-empty, messages joined, a result union) and keep their own clients.
12
+ */
13
+
14
+ /** Body-snippet length in the thrown errors. The value both connectors used. */
15
+ const SNIPPET_MAX = 400;
16
+
17
+ export interface GraphqlRequest {
18
+ /** The GraphQL endpoint. */
19
+ readonly url: string;
20
+ /** Service name that prefixes every failure, e.g. `"Wiz 401: ..."`. */
21
+ readonly label: string;
22
+ /** Auth headers; `Content-Type` and `Accept` (both `application/json`) are added after them. */
23
+ readonly headers: Readonly<Record<string, string>>;
24
+ readonly query: string;
25
+ /** Omitted from the request body entirely when undefined. */
26
+ readonly variables?: Record<string, unknown>;
27
+ }
28
+
29
+ /**
30
+ * POST `query` (and `variables`) to `url` and resolve to the response's `data`.
31
+ *
32
+ * Throws `"<label> <status>: <body snippet>"` on a non-2xx,
33
+ * `"<label> GraphQL error: <errors snippet>"` when the response carries `errors`,
34
+ * and `"<label> GraphQL: response missing \`data\` field"` when it carries no `data`.
35
+ */
36
+ export async function postGraphql<T>(request: GraphqlRequest): Promise<T> {
37
+ const res = await fetch(request.url, {
38
+ method: "POST",
39
+ headers: {
40
+ ...request.headers,
41
+ "Content-Type": "application/json",
42
+ Accept: "application/json",
43
+ },
44
+ body: JSON.stringify({ query: request.query, variables: request.variables }),
45
+ });
46
+ const text = await res.text();
47
+ if (!res.ok) {
48
+ throw new Error(`${request.label} ${String(res.status)}: ${text.slice(0, SNIPPET_MAX)}`);
49
+ }
50
+ const parsed = JSON.parse(text) as { data?: T; errors?: unknown };
51
+ if (parsed.errors !== undefined) {
52
+ throw new Error(
53
+ `${request.label} GraphQL error: ${JSON.stringify(parsed.errors).slice(0, SNIPPET_MAX)}`,
54
+ );
55
+ }
56
+ if (parsed.data === undefined) {
57
+ throw new Error(`${request.label} GraphQL: response missing \`data\` field`);
58
+ }
59
+ return parsed.data;
60
+ }
@@ -55,3 +55,94 @@ export function formatAddress(a: MailAddress): string {
55
55
  }
56
56
  return addr;
57
57
  }
58
+
59
+ /**
60
+ * Message ENVELOPE — RFC 2822 header fields only.
61
+ *
62
+ * These five declarations were written out identically in `imap/src/imap-core.ts`
63
+ * and `protonmail/src/mail-core.ts`, differing only in the `Imap`/`Mail` name
64
+ * prefix. Both connectors index the same thing through the same protocol, so
65
+ * there was never a second shape here — only a second copy. Each core now
66
+ * aliases these, keeping its own names for its own callers.
67
+ */
68
+ export interface MailEnvelope {
69
+ readonly date?: Date | string | null;
70
+ readonly subject?: string | null;
71
+ readonly messageId?: string | null;
72
+ readonly from?: readonly MailAddress[];
73
+ readonly to?: readonly MailAddress[];
74
+ readonly cc?: readonly MailAddress[];
75
+ }
76
+
77
+ /**
78
+ * Attachment METADATA only — filename, size, mimetype. NEVER the bytes.
79
+ * Derived from BODYSTRUCTURE.
80
+ */
81
+ export interface MailAttachmentMeta {
82
+ readonly filename: string | null;
83
+ readonly sizeBytes: number | null;
84
+ readonly mimeType: string | null;
85
+ }
86
+
87
+ /**
88
+ * A single mailbox message reduced to header + attachment-metadata + a capped
89
+ * text preview. This is the ONLY shape these connectors ever materialize; it
90
+ * has no field that could carry attachment content or a full body.
91
+ */
92
+ export interface MailMessageMeta {
93
+ readonly uid: number;
94
+ readonly mailbox: string;
95
+ readonly uidValidity: string | null;
96
+ readonly envelope: MailEnvelope;
97
+ readonly attachments: readonly MailAttachmentMeta[];
98
+ /** Plain-text body preview, already capped to {@link PREVIEW_MAX_CHARS}. */
99
+ readonly preview: string;
100
+ }
101
+
102
+ /** Listing options. */
103
+ export interface MailListOptions {
104
+ readonly mailbox?: string;
105
+ readonly limit?: number;
106
+ }
107
+
108
+ /** Search options — searches header/envelope fields, never body-content scans. */
109
+ export interface MailSearchOptions {
110
+ readonly query: string;
111
+ readonly mailbox?: string;
112
+ readonly limit?: number;
113
+ }
114
+
115
+ /**
116
+ * Minimal IMAP client surface these connectors depend on. Implemented for real
117
+ * by `shared/imapflow-adapter.ts` and by a fake in tests. Deliberately exposes
118
+ * ONLY header/attachment-metadata + capped-preview reads.
119
+ */
120
+ export interface MailClient {
121
+ /** List the most recent messages (header + attachment metadata + preview). */
122
+ list(options: MailListOptions): Promise<MailMessageMeta[]>;
123
+ /** Fetch one message's header + attachment metadata + preview by uid. */
124
+ get(uid: number, mailbox?: string): Promise<MailMessageMeta | null>;
125
+ /** Search by subject/from/to substring; returns matching message metas. */
126
+ search(options: MailSearchOptions): Promise<MailMessageMeta[]>;
127
+ }
128
+
129
+ /** Outgoing message for the SMTP send tool. */
130
+ export interface SendMailInput {
131
+ readonly to: string;
132
+ readonly subject: string;
133
+ readonly body: string;
134
+ readonly cc?: string;
135
+ readonly bcc?: string;
136
+ }
137
+
138
+ /** Result of a send (message id assigned by the SMTP server, if any). */
139
+ export interface SendMailResult {
140
+ readonly messageId: string | null;
141
+ readonly accepted: readonly string[];
142
+ readonly rejected: readonly string[];
143
+ }
144
+
145
+ /** Minimal SMTP mailer surface; implemented for real over nodemailer. */
146
+ export interface SmtpMailer {
147
+ send(input: SendMailInput): Promise<SendMailResult>;
148
+ }
@@ -1,4 +1,4 @@
1
- import type { WriteToolRegistrar } from "./consent-kit.ts";
1
+ import type { WriteToolConfig, WriteToolRegistrar } from "./consent-kit.ts";
2
2
  /**
3
3
  * Shared tool-layer helpers for the IMAP/JMAP email connectors (imap, protonmail).
4
4
  * Extracted to eliminate byte-identical duplication in tools.ts across those two
@@ -10,6 +10,10 @@ import type { WriteToolRegistrar } from "./consent-kit.ts";
10
10
  * connector-specific envelope/meta mappers are deliberately NOT extracted here
11
11
  * (they implement different local interfaces and are out of scope per the dedup
12
12
  * brief).
13
+ *
14
+ * The mail-SEND pieces — `sendArgs`, {@link outgoingMail} and {@link mailSendConsent} —
15
+ * serve every email connector that sends, whatever its transport: imap, protonmail and
16
+ * apple through the factory below, and fastmail (JMAP), gmail and outlook directly.
13
17
  */
14
18
 
15
19
  import { z } from "zod";
@@ -54,6 +58,55 @@ export const emailToolSchemas = {
54
58
  sendArgs,
55
59
  } as const;
56
60
 
61
+ // ---------------------------------------------------------------------------
62
+ // Shared mail-send helpers (every connector that sends or drafts a new message)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** What a send or draft tool hands its transport. */
66
+ export interface OutgoingMail {
67
+ readonly to: string;
68
+ readonly subject: string;
69
+ readonly body: string;
70
+ readonly cc?: string;
71
+ readonly bcc?: string;
72
+ }
73
+
74
+ /**
75
+ * The outgoing message for validated `sendArgs`-shaped input: an empty `cc` or `bcc` is
76
+ * left out rather than handed on as an empty header.
77
+ */
78
+ export function outgoingMail(args: {
79
+ readonly to: string;
80
+ readonly subject: string;
81
+ readonly body: string;
82
+ readonly cc?: string | undefined;
83
+ readonly bcc?: string | undefined;
84
+ }): OutgoingMail {
85
+ return {
86
+ to: args.to,
87
+ subject: args.subject,
88
+ body: args.body,
89
+ ...(args.cc !== undefined && args.cc !== "" ? { cc: args.cc } : {}),
90
+ ...(args.bcc !== undefined && args.bcc !== "" ? { bcc: args.bcc } : {}),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * The consent declaration every mail-send tool shares, given its action type (`mutates`).
96
+ * A sent mail cannot be recalled and nothing remains to query, so the recipient and
97
+ * subject ARE the pre-state; the recipient is what the write scope is checked against.
98
+ */
99
+ export function mailSendConsent<T extends { readonly to: string; readonly subject: string }>(
100
+ mutates: string,
101
+ ): WriteToolConfig<T> {
102
+ return {
103
+ mutates,
104
+ recoverable: false,
105
+ capturePreState: (p) => Promise.resolve({ to: p.to, subject: p.subject }),
106
+ scopeTargetOf: (p) => ({ kind: "recipient", value: p.to }),
107
+ };
108
+ }
109
+
57
110
  // ---------------------------------------------------------------------------
58
111
  // Shared view transformer (imap + protonmail tools.ts — viewMessage)
59
112
  // ---------------------------------------------------------------------------
@@ -259,30 +312,11 @@ export function registerEmailConnectorTools(opts: {
259
312
 
260
313
  registerWriteTool(
261
314
  `${toolPrefix}_mail_send`,
262
- {
263
- mutates: `${toolPrefix}.mail.send`,
264
- // A sent mail cannot be recalled and nothing remains to query, so the recipient and
265
- // subject ARE the pre-state.
266
- recoverable: false,
267
- capturePreState: (p) => Promise.resolve({ to: p.to, subject: p.subject }),
268
- scopeTargetOf: (p) => ({ kind: "recipient", value: p.to }),
269
- },
315
+ mailSendConsent(`${toolPrefix}.mail.send`),
270
316
  descriptions.send,
271
317
  sendArgs,
272
- async (parsedData): Promise<McpListResult> => {
273
- const parsed = { success: true as const, data: parsedData };
274
- const input: { to: string; subject: string; body: string; cc?: string; bcc?: string } = {
275
- to: parsed.data.to,
276
- subject: parsed.data.subject,
277
- body: parsed.data.body,
278
- };
279
- if (parsed.data.cc !== undefined && parsed.data.cc !== "") {
280
- input.cc = parsed.data.cc;
281
- }
282
- if (parsed.data.bcc !== undefined && parsed.data.bcc !== "") {
283
- input.bcc = parsed.data.bcc;
284
- }
285
- const res = await mailer.send(input);
318
+ async (args): Promise<McpListResult> => {
319
+ const res = await mailer.send(outgoingMail(args));
286
320
  return mcpJsonResult({
287
321
  messageId: res.messageId,
288
322
  accepted: res.accepted,
@@ -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
+ }