@lanes-sh/link 0.1.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.
Files changed (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
@@ -0,0 +1,385 @@
1
+ import PostalMime from 'postal-mime';
2
+ import type { AuditLogger } from '#audit';
3
+ import type { ConnectionInfo, ToolResult } from '#connectivity';
4
+ import type { BlobStore } from '#stores/blobs';
5
+ import { receiptFor, resolveAttachments } from '#connectivity/mail';
6
+ import { mailboxAttachments } from './attachment.ts';
7
+ import { quoted, type ImapClient, type ImapSession } from './client.ts';
8
+ import { asText, itemValue } from './parser.ts';
9
+ import { decodeMailboxName, encodeMailboxName } from './utf7.ts';
10
+ import type { Sender, SmtpTarget } from './send.ts';
11
+ import { error, json } from './result.ts';
12
+ import { SETTABLE_FLAGS } from './operations.ts';
13
+ import type { ImapConnectorOptions } from './index.ts';
14
+ import {
15
+ addressList,
16
+ formatAddress,
17
+ allowedFlags,
18
+ envelopeSummary,
19
+ flagList,
20
+ searchCriteria,
21
+ stripHtml,
22
+ uidList,
23
+ } from './parse.ts';
24
+
25
+ /**
26
+ * The seven operations, as free functions over a session.
27
+ *
28
+ * Separate from `index.ts` because the connector's job is to open a session and
29
+ * pick one of these; theirs is to know what a mailbox can do.
30
+ */
31
+
32
+
33
+ export async function listMailboxes(
34
+ session: ImapSession,
35
+ args: Readonly<Record<string, unknown>>,
36
+ ): Promise<ToolResult> {
37
+ const pattern = String(args['pattern'] ?? '*');
38
+ const result = await session.command(`LIST "" ${quoted(encodeMailboxName(pattern))}`);
39
+
40
+ const mailboxes = result.untagged
41
+ .filter((tokens) => asText(tokens[1]) === 'LIST')
42
+ .map((tokens) => {
43
+ const flags = tokens[2]?.kind === 'list' ? tokens[2].items : [];
44
+ return {
45
+ name: decodeMailboxName(asText(tokens[4]) ?? ''),
46
+ delimiter: asText(tokens[3]) ?? null,
47
+ // `\Sent`, `\Drafts`, `\Junk` and friends: the only reliable way to know
48
+ // which folder is which, since the *names* are localised.
49
+ flags: flags.map((flag) => asText(flag)).filter(Boolean),
50
+ };
51
+ });
52
+
53
+ return json({ mailboxes });
54
+ }
55
+
56
+ export async function searchMessages(
57
+ session: ImapSession,
58
+ args: Readonly<Record<string, unknown>>,
59
+ ): Promise<ToolResult> {
60
+ const name = String(args['mailbox'] ?? 'INBOX');
61
+ const limit = Math.min(Number(args['limit'] ?? 25) || 25, 100);
62
+
63
+ // EXAMINE, not SELECT: read-only by construction rather than by care.
64
+ await session.command(`EXAMINE ${quoted(encodeMailboxName(name))}`);
65
+
66
+ const criteria = searchCriteria(args);
67
+ const found = await session.command(`UID SEARCH ${criteria}`);
68
+
69
+ const uids = found.untagged
70
+ .filter((tokens) => asText(tokens[1]) === 'SEARCH')
71
+ .flatMap((tokens) => tokens.slice(2).map((token) => Number(asText(token))))
72
+ .filter((uid) => Number.isFinite(uid));
73
+
74
+ if (uids.length === 0) return json({ mailbox: name, criteria, messages: [] });
75
+
76
+ // Highest UIDs are the most recent, and the tail is what anyone means by
77
+ // "search my mail" — asking for all of a 40,000-message mailbox is not.
78
+ const wanted = uids.slice(-limit).reverse();
79
+
80
+ const fetched = await session.command(
81
+ `UID FETCH ${wanted.join(',')} (UID FLAGS INTERNALDATE RFC822.SIZE ENVELOPE)`,
82
+ );
83
+
84
+ const byUid = new Map<number, Record<string, unknown>>();
85
+ for (const tokens of fetched.untagged) {
86
+ if (asText(tokens[2]) !== 'FETCH' || tokens[3]?.kind !== 'list') continue;
87
+ const items = tokens[3].items;
88
+ const uid = Number(asText(itemValue(items, 'UID')));
89
+ if (!Number.isFinite(uid)) continue;
90
+
91
+ byUid.set(uid, {
92
+ uid,
93
+ date: asText(itemValue(items, 'INTERNALDATE')) ?? null,
94
+ size: Number(asText(itemValue(items, 'RFC822.SIZE'))) || null,
95
+ flags: flagList(itemValue(items, 'FLAGS')),
96
+ ...envelopeSummary(itemValue(items, 'ENVELOPE')),
97
+ });
98
+ }
99
+
100
+ return json({
101
+ mailbox: name,
102
+ criteria,
103
+ messages: wanted.map((uid) => byUid.get(uid)).filter(Boolean),
104
+ });
105
+ }
106
+
107
+ export async function getMessage(
108
+ session: ImapSession,
109
+ args: Readonly<Record<string, unknown>>,
110
+ maxBodyBytes: number,
111
+ ): Promise<ToolResult> {
112
+ const name = String(args['mailbox'] ?? 'INBOX');
113
+ const uid = Number(args['uid']);
114
+ if (!Number.isFinite(uid)) return error('uid is required and must be a number.');
115
+
116
+ await session.command(`EXAMINE ${quoted(encodeMailboxName(name))}`);
117
+
118
+ // BODY.PEEK, not BODY: fetching the body must not silently mark the message
119
+ // read behind the operator's back.
120
+ const fetched = await session.command(`UID FETCH ${uid} (UID FLAGS BODY.PEEK[])`);
121
+
122
+ const record = fetched.untagged.find(
123
+ (tokens) => asText(tokens[2]) === 'FETCH' && tokens[3]?.kind === 'list',
124
+ );
125
+ if (!record || record[3]?.kind !== 'list') {
126
+ return error(`No message with UID ${uid} in ${name}.`);
127
+ }
128
+
129
+ const items = record[3].items;
130
+ const body = itemValue(items, 'BODY');
131
+ if (!body || body.kind !== 'literal') return error(`Message ${uid} returned no body.`);
132
+
133
+ const mail = await PostalMime.parse(body.bytes);
134
+
135
+ const text = mail.text ?? (mail.html ? stripHtml(mail.html) : '');
136
+ const truncated = text.length > maxBodyBytes;
137
+
138
+ return json({
139
+ uid,
140
+ mailbox: name,
141
+ flags: flagList(itemValue(items, 'FLAGS')),
142
+ subject: mail.subject ?? null,
143
+ from: mail.from ? formatAddress(mail.from) : null,
144
+ to: (mail.to ?? []).map(formatAddress),
145
+ cc: (mail.cc ?? []).map(formatAddress),
146
+ date: mail.date ?? null,
147
+ message_id: mail.messageId ?? null,
148
+ in_reply_to: mail.inReplyTo ?? null,
149
+ ...(args['include_body'] === false
150
+ ? {}
151
+ : {
152
+ body: truncated
153
+ ? `${text.slice(0, maxBodyBytes)}\n[truncated — ${text.length - maxBodyBytes} more characters]`
154
+ : text,
155
+ body_truncated: truncated,
156
+ }),
157
+ // Metadata only. Handing over attachment *content* needs somewhere for an
158
+ // agent to fetch bytes from, which is what MCP resources are for and does
159
+ // not exist yet — so this says what is there rather than pretending.
160
+ attachments: mail.attachments.map((attachment) => ({
161
+ filename: attachment.filename ?? null,
162
+ mime_type: attachment.mimeType,
163
+ bytes:
164
+ typeof attachment.content === 'string'
165
+ ? attachment.content.length
166
+ : attachment.content.byteLength,
167
+ })),
168
+ });
169
+ }
170
+
171
+ export async function markMessages(
172
+ session: ImapSession,
173
+ args: Readonly<Record<string, unknown>>,
174
+ ): Promise<ToolResult> {
175
+ const name = String(args['mailbox'] ?? 'INBOX');
176
+ const uids = uidList(args['uids']);
177
+ if (uids.length === 0) return error('uids must list at least one message.');
178
+
179
+ const add = allowedFlags(args['add_flags']);
180
+ const remove = allowedFlags(args['remove_flags']);
181
+ if (add.length === 0 && remove.length === 0) {
182
+ return error(`Nothing to change. Settable flags are ${SETTABLE_FLAGS.join(' ')}.`);
183
+ }
184
+
185
+ await session.command(`SELECT ${quoted(encodeMailboxName(name))}`);
186
+ if (add.length > 0) await session.command(`UID STORE ${uids.join(',')} +FLAGS (${add.join(' ')})`);
187
+ if (remove.length > 0) {
188
+ await session.command(`UID STORE ${uids.join(',')} -FLAGS (${remove.join(' ')})`);
189
+ }
190
+
191
+ return json({ mailbox: name, uids, added: add, removed: remove });
192
+ }
193
+
194
+ export async function moveMessages(
195
+ session: ImapSession,
196
+ args: Readonly<Record<string, unknown>>,
197
+ ): Promise<ToolResult> {
198
+ const name = String(args['mailbox'] ?? 'INBOX');
199
+ const named = args['destination'] === undefined ? '' : String(args['destination']);
200
+ const flag = args['destination_flag'] === undefined ? '' : String(args['destination_flag']);
201
+ const uids = uidList(args['uids']);
202
+
203
+ // Exactly one, refused rather than resolved by precedence. ADR-017 makes the
204
+ // same call on attachment sources, for the same reason: silently preferring
205
+ // one would make the other look like it worked, and which folder the mail
206
+ // went to is the last thing anyone thinks to check.
207
+ if (named && flag) {
208
+ return error('Pass either destination or destination_flag, not both.');
209
+ }
210
+ if (!named && !flag) return error('destination or destination_flag is required.');
211
+ if (uids.length === 0) return error('uids must list at least one message.');
212
+
213
+ let destination = named;
214
+ if (flag) {
215
+ const resolved = await findMailboxByFlag(session, flag);
216
+ if (!resolved) {
217
+ const available = await mailboxNames(session);
218
+ return error(
219
+ `No mailbox advertises ${flag}. LIST reported: ${available.join(', ') || 'nothing'}. ` +
220
+ 'Pass destination with one of those names instead.',
221
+ );
222
+ }
223
+ destination = resolved;
224
+ }
225
+
226
+ await session.command(`SELECT ${quoted(encodeMailboxName(name))}`);
227
+ // UID MOVE only, never emulated with COPY + \Deleted + EXPUNGE: that fallback
228
+ // is how a failed move becomes deleted mail.
229
+ await session.command(`UID MOVE ${uids.join(',')} ${quoted(encodeMailboxName(destination))}`);
230
+
231
+ return json({ moved: uids.length, from: name, to: destination });
232
+ }
233
+
234
+ export async function sendMessage(
235
+ client: ImapClient,
236
+ options: ImapConnectorOptions,
237
+ send: Sender,
238
+ args: Readonly<Record<string, unknown>>,
239
+ audit: AuditLogger,
240
+ storage: BlobStore,
241
+ connection: ConnectionInfo,
242
+ ): Promise<ToolResult> {
243
+ if (!options.smtp) return error('This account has no SMTP server configured, so it cannot send.');
244
+
245
+ const to = (args['to'] as string[] | undefined) ?? [];
246
+ if (to.length === 0) return error('to must list at least one recipient.');
247
+
248
+ // Resolved before the credential is touched, because this is the step that
249
+ // fails on a caller's mistake — a wrong path, a URL that will not fetch — and
250
+ // failing there should not have opened a session or a socket first.
251
+ //
252
+ // The budget is three quarters of what the host accepts: attachments travel
253
+ // base64, so this is the raw weight that fits once encoded. The composed
254
+ // message is measured exactly below; this only avoids reading megabytes to then
255
+ // refuse them.
256
+ const encodedLimit = options.smtp.maxMessageBytes;
257
+ const attachments = await resolveAttachments(args['attachments'], {
258
+ maxTotalBytes: Math.floor((encodedLimit * 3) / 4),
259
+ mailbox: mailboxAttachments(client),
260
+ storage,
261
+ });
262
+
263
+ // Recorded before the send, so an attachment that was read off disk is in the
264
+ // log even if submission then fails. `redact` cannot express this: it can only
265
+ // keep an argument verbatim, and the raw argument may be an inline base64 file
266
+ // — the last thing an audit log should grow. These are the resolved facts
267
+ // instead, which is what `annotate` is for.
268
+ if (attachments.length > 0) {
269
+ audit.annotate({
270
+ attachments: attachments.map((attachment) => ({
271
+ filename: attachment.filename,
272
+ bytes: attachment.bytes.byteLength,
273
+ content_type: attachment.contentType,
274
+ sha256: attachment.sha256,
275
+ origin: attachment.origin,
276
+ })),
277
+ });
278
+ }
279
+
280
+ // The call wins, then the connection's own default. Neither is invented: with
281
+ // nothing configured the header stays a bare address rather than guessing a
282
+ // name for someone, which would be worse than showing none.
283
+ const configured = connection.config['from_name'];
284
+ const fromName =
285
+ (args['from_name'] as string | undefined) ??
286
+ (typeof configured === 'string' && configured.trim() !== '' ? configured : undefined);
287
+
288
+ const credential = await options.credential();
289
+ const sent = await send({
290
+ target: options.smtp,
291
+ credential,
292
+ from: credential.username,
293
+ ...(fromName ? { fromName } : {}),
294
+ message: {
295
+ to,
296
+ cc: args['cc'] as string[] | undefined,
297
+ bcc: args['bcc'] as string[] | undefined,
298
+ subject: String(args['subject'] ?? ''),
299
+ text: args['text'] as string | undefined,
300
+ html: args['html'] as string | undefined,
301
+ inReplyTo: args['in_reply_to'] as string | undefined,
302
+ ...(attachments.length > 0 ? { attachments } : {}),
303
+ },
304
+ });
305
+
306
+ // File a copy, best effort. The message is already delivered by this point, so
307
+ // a failure to append is worth reporting but must not read as a failure to
308
+ // send — that would have someone send it a second time.
309
+ let filed: string | null = null;
310
+ try {
311
+ filed = await client.run(
312
+ async (session) => {
313
+ const sentMailbox = await findSentMailbox(session);
314
+ if (!sentMailbox) return null;
315
+ await session.commandWithLiteral(
316
+ `APPEND ${quoted(encodeMailboxName(sentMailbox))} (\\Seen) {${sent.raw.length}}`,
317
+ sent.raw,
318
+ );
319
+ return sentMailbox;
320
+ },
321
+ { retry: false },
322
+ );
323
+ } catch {
324
+ filed = null;
325
+ }
326
+
327
+ const cc = (args['cc'] as string[] | undefined) ?? [];
328
+
329
+ return json({
330
+ sent: true,
331
+ message_id: sent.messageId,
332
+ recipients: to.length + cc.length,
333
+ filed_in: filed,
334
+ // Names, sizes and digests, never content. What went out is worth confirming
335
+ // — a caller that named a path deserves to know which file it got — but
336
+ // handing the bytes back would undo the point of resolving them here.
337
+ ...(attachments.length > 0 ? { attachments: attachments.map(receiptFor) } : {}),
338
+ ...(filed ? {} : { note: 'Sent, but no copy could be filed in the Sent mailbox.' }),
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Which mailbox carries a given special-use attribute.
344
+ *
345
+ * By the flag, never by name: iCloud calls Sent `Sent Messages`, Gmail
346
+ * `[Gmail]/Sent Mail`, and a German Dovecot `Gesendet`. Hardcoding any of those
347
+ * is how a sent copy silently stops being filed — and Junk has the same trap
348
+ * with more spellings, which is what generalised this from `findSentMailbox`.
349
+ */
350
+ export async function findMailboxByFlag(
351
+ session: ImapSession,
352
+ flag: string,
353
+ ): Promise<string | null> {
354
+ const wanted = flag.toLowerCase();
355
+ const result = await session.command('LIST "" "*"');
356
+
357
+ for (const tokens of result.untagged) {
358
+ if (asText(tokens[1]) !== 'LIST') continue;
359
+ const flags = tokens[2]?.kind === 'list' ? tokens[2].items : [];
360
+ if (flags.some((candidate) => asText(candidate)?.toLowerCase() === wanted)) {
361
+ return decodeMailboxName(asText(tokens[4]) ?? '');
362
+ }
363
+ }
364
+
365
+ return null;
366
+ }
367
+
368
+ /** Which mailbox is "Sent" — the send path's single caller of the above. */
369
+ export async function findSentMailbox(session: ImapSession): Promise<string | null> {
370
+ return findMailboxByFlag(session, '\\Sent');
371
+ }
372
+
373
+ /** Every mailbox name LIST reported, for an error that can say what was there. */
374
+ async function mailboxNames(session: ImapSession): Promise<string[]> {
375
+ const result = await session.command('LIST "" "*"');
376
+ return result.untagged
377
+ .filter((tokens) => asText(tokens[1]) === 'LIST')
378
+ .map((tokens) => decodeMailboxName(asText(tokens[4]) ?? ''))
379
+ .filter((name) => name.length > 0);
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Shaping arguments and results
384
+ // ---------------------------------------------------------------------------
385
+
@@ -0,0 +1,152 @@
1
+ import PostalMime from 'postal-mime';
2
+ import {
3
+ type Connector,
4
+ type DiscoveredCapability,
5
+ type ToolResult,
6
+ } from '#connectivity';
7
+ import {
8
+ createImapClient,
9
+ quoted,
10
+ type ImapClient,
11
+ type ImapCredential,
12
+ type ImapSession,
13
+ } from './client.ts';
14
+ import { asText, itemValue, type ImapToken } from './parser.ts';
15
+ import { decodeMailboxName, encodeMailboxName } from './utf7.ts';
16
+ import { sendOverSmtp, type Sender, type SmtpTarget } from './send.ts';
17
+ import type { SocketFactory } from './socket.ts';
18
+
19
+ /**
20
+ * The `imap` connector — a mailbox, over the protocol every mail host speaks.
21
+ *
22
+ * Vendor-neutral by construction: iCloud, Fastmail, and a company Dovecot are
23
+ * the same six manifest fields. There is no iCloud anywhere in this file, and
24
+ * that is the test a new connector kind has to pass — protocol code, not vendor
25
+ * code.
26
+ *
27
+ * Unlike `http`, there is nothing to read a capability list *from*: IMAP
28
+ * describes its extensions through CAPABILITY but never its operations, so the
29
+ * set below is fixed by RFC 3501 rather than by the vendor. `discover()` still
30
+ * does real work — it logs in, so a wrong password fails at `connect` rather
31
+ * than mid-task three days later, and it conditions the set on what the server
32
+ * actually supports.
33
+ *
34
+ * **Reading never marks anything read.** Every read path uses `EXAMINE` rather
35
+ * than `SELECT` and `BODY.PEEK[]` rather than `BODY[]`. Marking a message seen
36
+ * is reachable only through `mark_messages`, which is in the write bundle —
37
+ * never as an argument to a read capability, because an argument that flips a
38
+ * capability's bundle defeats the split the policy is expressed in.
39
+ *
40
+ * **Nothing here can destroy mail.** No `EXPUNGE`, and `\Deleted` is not in the
41
+ * flag allowlist. An agent that can permanently erase a mailbox is a different
42
+ * risk class from one that can read it, and IMAP's delete is not recoverable
43
+ * through this connector. If it is ever wanted, it is a third bundle.
44
+ */
45
+
46
+ import { imapCapabilities } from './capabilities.ts';
47
+ import { OPERATIONS } from './operations.ts';
48
+ import { error, json } from './result.ts';
49
+ import {
50
+ getMessage,
51
+ listMailboxes,
52
+ markMessages,
53
+ moveMessages,
54
+ searchMessages,
55
+ sendMessage,
56
+ } from './commands.ts';
57
+
58
+ export interface ImapConnectorOptions {
59
+ /**
60
+ * One line to append when the server refuses the credential, in the
61
+ * provider's own words. See the identical field on `DavConnectorOptions`.
62
+ */
63
+ readonly troubleshooting?: string | undefined;
64
+ readonly host: string;
65
+ readonly port: number;
66
+ readonly smtp?: SmtpTarget | undefined;
67
+ readonly maxBodyBytes: number;
68
+ readonly credential: () => Promise<ImapCredential>;
69
+ /** Injected in tests, so no test needs a server. */
70
+ readonly socket?: SocketFactory | undefined;
71
+ readonly send?: Sender | undefined;
72
+ readonly idleMs?: number | undefined;
73
+ }
74
+
75
+
76
+ export function createImapConnector(options: ImapConnectorOptions): Connector {
77
+ const client: ImapClient = createImapClient({
78
+ host: options.host,
79
+ port: options.port,
80
+ credential: options.credential,
81
+ ...(options.socket ? { socket: options.socket } : {}),
82
+ ...(options.idleMs === undefined ? {} : { idleMs: options.idleMs }),
83
+ ...(options.troubleshooting === undefined ? {} : { troubleshooting: options.troubleshooting }),
84
+ });
85
+
86
+ const send = options.send ?? sendOverSmtp;
87
+
88
+ return {
89
+ kind: 'imap',
90
+
91
+ async discover(): Promise<DiscoveredCapability[]> {
92
+ // Logging in is the point of discovering: a rejected app-specific
93
+ // password should stop `connect`, not surface later as a failed tool call.
94
+ const supportsMove = await client.run(async (session) => session.capabilities.has('MOVE'));
95
+
96
+ return imapCapabilities({ supportsMove, canSend: options.smtp !== undefined });
97
+ },
98
+
99
+ async identify(): Promise<string | null> {
100
+ // The account is the name the *server accepted*, which is a stronger claim
101
+ // than the one the operator typed — a typo fails here rather than becoming
102
+ // a permanent mislabel in config.
103
+ return client.run(async (session) => session.username);
104
+ },
105
+
106
+ async invoke(capability, args, context): Promise<ToolResult> {
107
+ const operation = String(capability.target?.['operation'] ?? capability.name);
108
+
109
+ try {
110
+ switch (operation) {
111
+ case OPERATIONS.listMailboxes:
112
+ return await client.run((session) => listMailboxes(session, args));
113
+ case OPERATIONS.searchMessages:
114
+ return await client.run((session) => searchMessages(session, args));
115
+ case OPERATIONS.getMessage:
116
+ return await client.run((session) => getMessage(session, args, options.maxBodyBytes));
117
+ case OPERATIONS.markMessages:
118
+ // Never retried. A repeated flag change is harmless, but keeping the
119
+ // rule uniform is what stops the exceptions from multiplying.
120
+ return await client.run((session) => markMessages(session, args), { retry: false });
121
+ case OPERATIONS.moveMessages:
122
+ return await client.run((session) => moveMessages(session, args), { retry: false });
123
+ case OPERATIONS.sendMessage:
124
+ // The only operation handed the context: what it attached is worth
125
+ // recording in a way `redact` cannot express, since the raw argument
126
+ // may itself be a base64 file.
127
+ return await sendMessage(
128
+ client,
129
+ options,
130
+ send,
131
+ args,
132
+ context.provider.audit,
133
+ context.provider.storage,
134
+ context.provider.connection,
135
+ );
136
+ default:
137
+ return error(`Unknown operation "${operation}".`);
138
+ }
139
+ } catch (failure) {
140
+ // A protocol rejection is the server answering, not a fault of ours, so
141
+ // it comes back as a tool error the agent can read — the same treatment
142
+ // `http.ts` gives a non-2xx.
143
+ return error((failure as Error).message);
144
+ }
145
+ },
146
+
147
+ close: () => client.close(),
148
+ };
149
+ }
150
+
151
+ // Re-exported for the tests that cover IMAP's two peculiar encodings directly.
152
+ export { imapDate, searchCriteria } from './parse.ts';
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The operation names an IMAP connection exposes, and the argument shapes they
3
+ * share.
4
+ *
5
+ * Their own file because `capabilities.ts` declares them and `index.ts`
6
+ * dispatches on them: a name spelled differently in the two places is a tool
7
+ * that lists and cannot be called.
8
+ */
9
+
10
+ export const OPERATIONS = {
11
+ listMailboxes: 'list_mailboxes',
12
+ searchMessages: 'search_messages',
13
+ getMessage: 'get_message',
14
+ markMessages: 'mark_messages',
15
+ moveMessages: 'move_messages',
16
+ sendMessage: 'send_message',
17
+ } as const;
18
+
19
+ /** The flags an agent may set. `\Deleted` is deliberately absent. */
20
+ export const SETTABLE_FLAGS = ['\\Seen', '\\Flagged', '\\Answered', '\\Draft'] as const;
21
+
22
+ /**
23
+ * The RFC 6154 special-use attributes a move may name a destination by.
24
+ *
25
+ * A different allowlist from `SETTABLE_FLAGS` and not to be merged with it:
26
+ * these are mailbox attributes reported by LIST, not message flags set by
27
+ * STORE. `\All` is omitted because it is a virtual mailbox — a MOVE into it
28
+ * means nothing — and `\Deleted` is not in this vocabulary at all.
29
+ *
30
+ * This exists because naming a mailbox is not portable. `findSentMailbox`
31
+ * already carried the argument: iCloud spells Sent `Sent Messages`, Gmail
32
+ * `[Gmail]/Sent Mail`, a German Dovecot `Gesendet`. Junk is the same trap with
33
+ * more spellings — `Junk`, `Spam`, `Junk E-mail` — and it is the one an agent
34
+ * asked to "mark this as spam" has to get right, because moving mail to a
35
+ * mailbox that does not exist under that name is how the move silently fails.
36
+ *
37
+ * Deliberately not a `report_junk` operation of its own. That would be
38
+ * `move_messages` under a second name — the `labels.patch` objection. The one
39
+ * thing that would make it more than a rename is the trained signal, and on
40
+ * IMAP that is the `$Junk` keyword plus removing `$NotJunk`; those are
41
+ * keywords, not system flags, so admitting them means widening
42
+ * `SETTABLE_FLAGS` into a different class of thing, with its own argument to
43
+ * make. Until that argument is made, a `report_junk` would advertise a training
44
+ * signal it does not send. The move to `\Junk` is what the server actually
45
+ * learns from, so the move is the honest capability.
46
+ */
47
+ export const SPECIAL_USE_FLAGS = ['\\Junk', '\\Archive', '\\Trash', '\\Sent', '\\Drafts'] as const;
48
+
49
+ export const object = (
50
+ properties: Record<string, unknown>,
51
+ required: string[] = [],
52
+ ): Record<string, unknown> => ({
53
+ type: 'object',
54
+ properties,
55
+ ...(required.length > 0 ? { required } : {}),
56
+ additionalProperties: false,
57
+ });
58
+
59
+ export const mailboxArgument = {
60
+ type: 'string',
61
+ default: 'INBOX',
62
+ description: 'Mailbox name as list_mailboxes reports it.',
63
+ };
64
+