@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.
- package/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- package/src/stores/state/testing.ts +41 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { quoted } from './client.ts';
|
|
2
|
+
import { asText, itemValue, type ImapToken } from './parser.ts';
|
|
3
|
+
import { SETTABLE_FLAGS } from './operations.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Turning IMAP's wire vocabulary into ordinary values.
|
|
7
|
+
*
|
|
8
|
+
* Dates in its own peculiar `DD-Mon-YYYY`, SEARCH criteria, flag lists, and the
|
|
9
|
+
* ENVELOPE structure — none of which any other transport has an opinion about.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
13
|
+
|
|
14
|
+
/** IMAP wants `1-Aug-2026`, and rejects anything else without explaining. */
|
|
15
|
+
export function imapDate(value: string): string | undefined {
|
|
16
|
+
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
17
|
+
if (!match) return undefined;
|
|
18
|
+
return `${Number(match[3])}-${MONTHS[Number(match[2]) - 1]}-${match[1]}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function searchCriteria(args: Readonly<Record<string, unknown>>): string {
|
|
22
|
+
const parts: string[] = [];
|
|
23
|
+
|
|
24
|
+
const term = (key: string, keyword: string): void => {
|
|
25
|
+
const value = args[key];
|
|
26
|
+
if (typeof value === 'string' && value) parts.push(`${keyword} ${quoted(value)}`);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
term('from', 'FROM');
|
|
30
|
+
term('to', 'TO');
|
|
31
|
+
term('subject', 'SUBJECT');
|
|
32
|
+
term('text', 'TEXT');
|
|
33
|
+
|
|
34
|
+
for (const [key, keyword] of [
|
|
35
|
+
['since', 'SINCE'],
|
|
36
|
+
['before', 'BEFORE'],
|
|
37
|
+
] as const) {
|
|
38
|
+
const value = args[key];
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
const formatted = imapDate(value);
|
|
41
|
+
if (formatted) parts.push(`${keyword} ${formatted}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (args['unseen'] === true) parts.push('UNSEEN');
|
|
46
|
+
if (args['flagged'] === true) parts.push('FLAGGED');
|
|
47
|
+
|
|
48
|
+
return parts.length > 0 ? parts.join(' ') : 'ALL';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function uidList(value: unknown): number[] {
|
|
52
|
+
if (!Array.isArray(value)) return [];
|
|
53
|
+
return value.map((entry) => Number(entry)).filter((uid) => Number.isFinite(uid) && uid > 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Only flags on the allowlist, silently dropping the rest.
|
|
58
|
+
*
|
|
59
|
+
* `\Deleted` is the one that matters: accepting it would let an agent stage a
|
|
60
|
+
* mailbox for destruction through a capability whose name says "mark".
|
|
61
|
+
*/
|
|
62
|
+
export function allowedFlags(value: unknown): string[] {
|
|
63
|
+
if (!Array.isArray(value)) return [];
|
|
64
|
+
const allowed = new Set<string>(SETTABLE_FLAGS);
|
|
65
|
+
return value.map(String).filter((flag) => allowed.has(flag));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function flagList(token: ImapToken | undefined): string[] {
|
|
69
|
+
if (!token || token.kind !== 'list') return [];
|
|
70
|
+
return token.items.map((item) => asText(item)).filter((flag): flag is string => Boolean(flag));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The parts of an ENVELOPE worth showing.
|
|
75
|
+
*
|
|
76
|
+
* Positional, per RFC 3501: date, subject, from, sender, reply-to, to, cc, bcc,
|
|
77
|
+
* in-reply-to, message-id. Addresses are `(name adl mailbox host)`.
|
|
78
|
+
*/
|
|
79
|
+
export function envelopeSummary(token: ImapToken | undefined): Record<string, unknown> {
|
|
80
|
+
if (!token || token.kind !== 'list') return {};
|
|
81
|
+
const fields = token.items;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
subject: asText(fields[1]) ?? null,
|
|
85
|
+
from: addressList(fields[2])[0] ?? null,
|
|
86
|
+
to: addressList(fields[5]),
|
|
87
|
+
message_id: asText(fields[9]) ?? null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function addressList(token: ImapToken | undefined): string[] {
|
|
92
|
+
if (!token || token.kind !== 'list') return [];
|
|
93
|
+
|
|
94
|
+
return token.items
|
|
95
|
+
.map((entry) => {
|
|
96
|
+
if (entry.kind !== 'list') return null;
|
|
97
|
+
const name = asText(entry.items[0]);
|
|
98
|
+
const local = asText(entry.items[2]);
|
|
99
|
+
const host = asText(entry.items[3]);
|
|
100
|
+
if (!local || !host) return null;
|
|
101
|
+
const address = `${local}@${host}`;
|
|
102
|
+
return name ? `${name} <${address}>` : address;
|
|
103
|
+
})
|
|
104
|
+
.filter((entry): entry is string => entry !== null);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatAddress(address: {
|
|
108
|
+
name?: string | undefined;
|
|
109
|
+
address?: string | undefined;
|
|
110
|
+
}): string {
|
|
111
|
+
// A group address (`undisclosed-recipients:;`) has a name and no address.
|
|
112
|
+
if (!address.address) return address.name ?? '';
|
|
113
|
+
return address.name ? `${address.name} <${address.address}>` : address.address;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A readable fallback when a message carries only HTML. */
|
|
117
|
+
export function stripHtml(html: string): string {
|
|
118
|
+
return html
|
|
119
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
|
|
120
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
121
|
+
.replace(/<\/(p|div|tr|li|h[1-6])>/gi, '\n')
|
|
122
|
+
.replace(/<[^>]+>/g, '')
|
|
123
|
+
.replace(/ /g, ' ')
|
|
124
|
+
.replace(/&/g, '&')
|
|
125
|
+
.replace(/</g, '<')
|
|
126
|
+
.replace(/>/g, '>')
|
|
127
|
+
.replace(/"/g, '"')
|
|
128
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
129
|
+
.trim();
|
|
130
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading IMAP responses.
|
|
3
|
+
*
|
|
4
|
+
* The one thing that must not be done line-wise. A response may announce a
|
|
5
|
+
* literal — `{1234}` at the end of a line — and the next 1234 bytes are then
|
|
6
|
+
* *arbitrary*: a message body containing CRLF, quotes, parentheses, and NUL. A
|
|
7
|
+
* reader that splits on CRLF tears those apart and produces garbage that looks
|
|
8
|
+
* like a protocol error, which is why this assembles by counting bytes and only
|
|
9
|
+
* then tokenises.
|
|
10
|
+
*
|
|
11
|
+
* Bytes rather than strings all the way through, because `BODY[]` is the raw
|
|
12
|
+
* RFC 822 message and decoding it as UTF-8 before the MIME layer has read its
|
|
13
|
+
* charset would corrupt every non-UTF-8 mail in the box.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const CR = 0x0d;
|
|
17
|
+
const LF = 0x0a;
|
|
18
|
+
|
|
19
|
+
/** A parsed element of a response. */
|
|
20
|
+
export type ImapToken =
|
|
21
|
+
| { readonly kind: 'atom'; readonly value: string }
|
|
22
|
+
| { readonly kind: 'quoted'; readonly value: string }
|
|
23
|
+
| { readonly kind: 'literal'; readonly bytes: Uint8Array }
|
|
24
|
+
| { readonly kind: 'list'; readonly items: readonly ImapToken[] };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Accumulates bytes and hands back one complete logical response at a time.
|
|
28
|
+
*
|
|
29
|
+
* "Logical" is the important word: a single response can span several physical
|
|
30
|
+
* lines whenever a literal is involved, and it is not complete until every
|
|
31
|
+
* announced literal has arrived in full.
|
|
32
|
+
*/
|
|
33
|
+
export class ResponseAssembler {
|
|
34
|
+
#buffer = new Uint8Array(0);
|
|
35
|
+
|
|
36
|
+
push(chunk: Uint8Array): void {
|
|
37
|
+
const merged = new Uint8Array(this.#buffer.length + chunk.length);
|
|
38
|
+
merged.set(this.#buffer);
|
|
39
|
+
merged.set(chunk, this.#buffer.length);
|
|
40
|
+
this.#buffer = merged;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The next complete response, or undefined while more bytes are needed. */
|
|
44
|
+
next(): Uint8Array | undefined {
|
|
45
|
+
const end = completeResponseEnd(this.#buffer);
|
|
46
|
+
if (end === undefined) return undefined;
|
|
47
|
+
|
|
48
|
+
const response = this.#buffer.subarray(0, end);
|
|
49
|
+
this.#buffer = this.#buffer.slice(end);
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whatever has arrived but does not yet form a response. For diagnostics. */
|
|
54
|
+
get pending(): number {
|
|
55
|
+
return this.#buffer.length;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Where a complete response ends, counting past every literal it announces. */
|
|
60
|
+
function completeResponseEnd(buffer: Uint8Array): number | undefined {
|
|
61
|
+
let cursor = 0;
|
|
62
|
+
|
|
63
|
+
for (;;) {
|
|
64
|
+
const crlf = indexOfCrlf(buffer, cursor);
|
|
65
|
+
if (crlf === -1) return undefined;
|
|
66
|
+
|
|
67
|
+
const announced = literalLength(buffer, cursor, crlf);
|
|
68
|
+
if (announced === undefined) return crlf + 2;
|
|
69
|
+
|
|
70
|
+
const afterLiteral = crlf + 2 + announced;
|
|
71
|
+
if (buffer.length < afterLiteral) return undefined;
|
|
72
|
+
|
|
73
|
+
// The literal is in hand; the response continues after it, and may announce
|
|
74
|
+
// another — `BODY[HEADER] {12} ... BODY[TEXT] {900} ...` in one FETCH.
|
|
75
|
+
cursor = afterLiteral;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function indexOfCrlf(buffer: Uint8Array, from: number): number {
|
|
80
|
+
for (let index = from; index + 1 < buffer.length; index++) {
|
|
81
|
+
if (buffer[index] === CR && buffer[index + 1] === LF) return index;
|
|
82
|
+
}
|
|
83
|
+
return -1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The size a line announces, if it ends in a literal marker.
|
|
88
|
+
*
|
|
89
|
+
* `{123}` or `{123+}` — the `+` form is LITERAL+, which a server uses to say it
|
|
90
|
+
* is not waiting for a continuation.
|
|
91
|
+
*/
|
|
92
|
+
function literalLength(buffer: Uint8Array, from: number, crlf: number): number | undefined {
|
|
93
|
+
let index = crlf - 1;
|
|
94
|
+
if (index < from) return undefined;
|
|
95
|
+
|
|
96
|
+
// Read right to left: `}`, then an optional `+` *inside* the braces, then the
|
|
97
|
+
// digits, then `{`.
|
|
98
|
+
if (buffer[index] !== 0x7d /* } */) return undefined;
|
|
99
|
+
index--;
|
|
100
|
+
if (index >= from && buffer[index] === 0x2b /* + */) index--; // LITERAL+
|
|
101
|
+
|
|
102
|
+
let digits = '';
|
|
103
|
+
while (index >= from && buffer[index]! >= 0x30 && buffer[index]! <= 0x39) {
|
|
104
|
+
digits = String.fromCharCode(buffer[index]!) + digits;
|
|
105
|
+
index--;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (digits === '' || index < from || buffer[index] !== 0x7b /* { */) return undefined;
|
|
109
|
+
return Number(digits);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Turn one complete response into tokens.
|
|
114
|
+
*
|
|
115
|
+
* Brackets are deliberately kept inside atoms: `BODY[HEADER.FIELDS (FROM DATE)]`
|
|
116
|
+
* is *one* item name, parentheses and all, and splitting it on the space would
|
|
117
|
+
* turn the FETCH item into two unrecognisable ones.
|
|
118
|
+
*/
|
|
119
|
+
export function tokenize(response: Uint8Array): ImapToken[] {
|
|
120
|
+
const state = { at: 0 };
|
|
121
|
+
const tokens = readTokens(response, state, null);
|
|
122
|
+
return tokens;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readTokens(
|
|
126
|
+
buffer: Uint8Array,
|
|
127
|
+
state: { at: number },
|
|
128
|
+
closer: number | null,
|
|
129
|
+
): ImapToken[] {
|
|
130
|
+
const tokens: ImapToken[] = [];
|
|
131
|
+
|
|
132
|
+
while (state.at < buffer.length) {
|
|
133
|
+
const byte = buffer[state.at]!;
|
|
134
|
+
|
|
135
|
+
if (byte === CR || byte === LF) {
|
|
136
|
+
// A bare CRLF ends the response; inside a list it is part of a literal's
|
|
137
|
+
// surroundings and simply skipped.
|
|
138
|
+
state.at++;
|
|
139
|
+
if (closer === null) break;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (byte === 0x20 /* space */) {
|
|
144
|
+
state.at++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (closer !== null && byte === closer) {
|
|
149
|
+
state.at++;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (byte === 0x28 /* ( */) {
|
|
154
|
+
state.at++;
|
|
155
|
+
tokens.push({ kind: 'list', items: readTokens(buffer, state, 0x29 /* ) */) });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (byte === 0x22 /* " */) {
|
|
160
|
+
tokens.push({ kind: 'quoted', value: readQuoted(buffer, state) });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (byte === 0x7b /* { */) {
|
|
165
|
+
const literal = readLiteral(buffer, state);
|
|
166
|
+
if (literal) {
|
|
167
|
+
tokens.push({ kind: 'literal', bytes: literal });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
tokens.push({ kind: 'atom', value: readAtom(buffer, state, closer) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return tokens;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function readQuoted(buffer: Uint8Array, state: { at: number }): string {
|
|
179
|
+
state.at++; // opening quote
|
|
180
|
+
const bytes: number[] = [];
|
|
181
|
+
|
|
182
|
+
while (state.at < buffer.length) {
|
|
183
|
+
const byte = buffer[state.at]!;
|
|
184
|
+
if (byte === 0x5c /* \ */ && state.at + 1 < buffer.length) {
|
|
185
|
+
bytes.push(buffer[state.at + 1]!);
|
|
186
|
+
state.at += 2;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (byte === 0x22) {
|
|
190
|
+
state.at++;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
bytes.push(byte);
|
|
194
|
+
state.at++;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readLiteral(buffer: Uint8Array, state: { at: number }): Uint8Array | undefined {
|
|
201
|
+
const close = buffer.indexOf(0x7d /* } */, state.at);
|
|
202
|
+
if (close === -1) return undefined;
|
|
203
|
+
|
|
204
|
+
const header = new TextDecoder().decode(buffer.subarray(state.at + 1, close));
|
|
205
|
+
const size = Number(header.replace('+', ''));
|
|
206
|
+
if (!Number.isFinite(size)) return undefined;
|
|
207
|
+
|
|
208
|
+
// The size is followed by CRLF, then exactly `size` bytes.
|
|
209
|
+
let start = close + 1;
|
|
210
|
+
if (buffer[start] === CR) start++;
|
|
211
|
+
if (buffer[start] === LF) start++;
|
|
212
|
+
|
|
213
|
+
const end = Math.min(start + size, buffer.length);
|
|
214
|
+
state.at = end;
|
|
215
|
+
return buffer.subarray(start, end);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readAtom(buffer: Uint8Array, state: { at: number }, closer: number | null): string {
|
|
219
|
+
const start = state.at;
|
|
220
|
+
let brackets = 0;
|
|
221
|
+
|
|
222
|
+
while (state.at < buffer.length) {
|
|
223
|
+
const byte = buffer[state.at]!;
|
|
224
|
+
|
|
225
|
+
if (byte === 0x5b /* [ */) brackets++;
|
|
226
|
+
else if (byte === 0x5d /* ] */) brackets--;
|
|
227
|
+
else if (brackets === 0) {
|
|
228
|
+
if (byte === 0x20 || byte === CR || byte === LF) break;
|
|
229
|
+
if (byte === 0x28 || byte === 0x29) break;
|
|
230
|
+
if (closer !== null && byte === closer) break;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
state.at++;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return new TextDecoder().decode(buffer.subarray(start, state.at));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Reading what was parsed
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/** The text of any token that has one. `NIL` is an absent value, not the word. */
|
|
244
|
+
export function asText(token: ImapToken | undefined): string | undefined {
|
|
245
|
+
if (!token) return undefined;
|
|
246
|
+
if (token.kind === 'atom') return token.value === 'NIL' ? undefined : token.value;
|
|
247
|
+
if (token.kind === 'quoted') return token.value;
|
|
248
|
+
if (token.kind === 'literal') return new TextDecoder().decode(token.bytes);
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The value following a named item, as FETCH lays them out.
|
|
254
|
+
*
|
|
255
|
+
* A FETCH response is a flat alternation — `UID 5 FLAGS (\Seen) RFC822.SIZE 42`
|
|
256
|
+
* — so an item's value is simply the token after its name. Matching is prefix-
|
|
257
|
+
* based because `BODY[]` and `BODY[HEADER.FIELDS (DATE)]` are different names
|
|
258
|
+
* for the same request depending on what was asked for.
|
|
259
|
+
*/
|
|
260
|
+
export function itemValue(
|
|
261
|
+
items: readonly ImapToken[],
|
|
262
|
+
name: string,
|
|
263
|
+
): ImapToken | undefined {
|
|
264
|
+
for (let index = 0; index < items.length; index++) {
|
|
265
|
+
const token = items[index]!;
|
|
266
|
+
if (token.kind !== 'atom') continue;
|
|
267
|
+
if (token.value === name || token.value.toUpperCase().startsWith(`${name.toUpperCase()}[`)) {
|
|
268
|
+
return items[index + 1];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ToolResult } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/** The two result shapes every operation returns. */
|
|
4
|
+
|
|
5
|
+
export function error(message: string): ToolResult {
|
|
6
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function json(value: unknown): ToolResult {
|
|
10
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Operations
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
import { composeMime, type ComposedMessage, type OutgoingMessage } from '#connectivity/mail';
|
|
3
|
+
import type { ImapCredential } from './client.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SMTP submission.
|
|
7
|
+
*
|
|
8
|
+
* Composition itself lives in `#connectivity/mail` — it grew a second caller
|
|
9
|
+
* once an HTTP mail API needed the same assembled bytes, and one MIME builder is
|
|
10
|
+
* the difference between one set of boundary bugs and two. What stays here is the
|
|
11
|
+
* part that is actually SMTP: the envelope, the TLS decision, and the socket.
|
|
12
|
+
*
|
|
13
|
+
* The message is composed once and then both sent *and* appended, so the copy in
|
|
14
|
+
* Sent is byte-identical to what the recipient got rather than a re-render of the
|
|
15
|
+
* same intent.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface SmtpTarget {
|
|
19
|
+
readonly host: string;
|
|
20
|
+
readonly port: number;
|
|
21
|
+
readonly starttls: boolean;
|
|
22
|
+
/** Largest encoded message this host accepts. See the manifest field. */
|
|
23
|
+
readonly maxMessageBytes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type { OutgoingMessage };
|
|
27
|
+
|
|
28
|
+
/** What went out, and the exact bytes of it, for appending to Sent. */
|
|
29
|
+
export type SentMessage = ComposedMessage;
|
|
30
|
+
|
|
31
|
+
export type Sender = (input: {
|
|
32
|
+
target: SmtpTarget;
|
|
33
|
+
credential: ImapCredential;
|
|
34
|
+
from: string;
|
|
35
|
+
/**
|
|
36
|
+
* The name shown beside the address.
|
|
37
|
+
*
|
|
38
|
+
* SMTP submits exactly what we compose — unlike an HTTP mail API, there is no
|
|
39
|
+
* server-side step that fills in the account's own name — so without this the
|
|
40
|
+
* `From` header is a bare address, which is what makes a sent message look
|
|
41
|
+
* machine-generated.
|
|
42
|
+
*/
|
|
43
|
+
fromName?: string | undefined;
|
|
44
|
+
message: OutgoingMessage;
|
|
45
|
+
}) => Promise<SentMessage>;
|
|
46
|
+
|
|
47
|
+
export const sendOverSmtp: Sender = async ({
|
|
48
|
+
target,
|
|
49
|
+
credential,
|
|
50
|
+
from,
|
|
51
|
+
fromName,
|
|
52
|
+
message,
|
|
53
|
+
}) => {
|
|
54
|
+
const envelope = {
|
|
55
|
+
from,
|
|
56
|
+
to: [...message.to, ...(message.cc ?? []), ...(message.bcc ?? [])],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// The envelope keeps the bare address: SMTP's MAIL FROM is a routing
|
|
60
|
+
// identity, not a display one, and a name there is a protocol error.
|
|
61
|
+
const built = await composeMime({ from, fromName, message });
|
|
62
|
+
|
|
63
|
+
// Checked here rather than by the caller because this is the only point where
|
|
64
|
+
// the real number exists: base64 expansion, headers and boundaries are all
|
|
65
|
+
// decided by composition, so any earlier figure is an estimate. And it must be
|
|
66
|
+
// before submission — a message refused part-way through `DATA` has already
|
|
67
|
+
// cost the upload and reads like a dropped connection.
|
|
68
|
+
if (built.raw.byteLength > target.maxMessageBytes) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`The composed message is ${built.raw.byteLength} bytes and ${target.host} accepts ${target.maxMessageBytes}. Attachments are base64 in transit, so they weigh about a third more than on disk.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const transport = nodemailer.createTransport({
|
|
75
|
+
host: target.host,
|
|
76
|
+
port: target.port,
|
|
77
|
+
// 465 is implicit TLS; 587 upgrades. `requireTLS` makes the upgrade
|
|
78
|
+
// mandatory rather than best-effort — without it a downgrade would send the
|
|
79
|
+
// password in the clear.
|
|
80
|
+
secure: !target.starttls,
|
|
81
|
+
requireTLS: target.starttls,
|
|
82
|
+
auth: { user: credential.username, pass: credential.password },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await transport.sendMail({ raw: built.raw, envelope });
|
|
87
|
+
} finally {
|
|
88
|
+
transport.close();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return built;
|
|
92
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place this connector touches a socket.
|
|
3
|
+
*
|
|
4
|
+
* Behind an interface for the same reason `HttpConnectorOptions.fetch` is: a
|
|
5
|
+
* test needs to drive the protocol without a server, and the protocol is where
|
|
6
|
+
* the bugs are. Everything above this file speaks in bytes and never learns
|
|
7
|
+
* whether they came from Bun, from Node, or from an array in a test.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ImapSocket {
|
|
11
|
+
write(data: Uint8Array): Promise<void>;
|
|
12
|
+
/** The next chunk, or null once the peer has gone away. */
|
|
13
|
+
read(): Promise<Uint8Array | null>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SocketTarget {
|
|
18
|
+
readonly host: string;
|
|
19
|
+
readonly port: number;
|
|
20
|
+
readonly signal?: AbortSignal | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SocketFactory = (target: SocketTarget) => Promise<ImapSocket>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Connect over implicit TLS.
|
|
27
|
+
*
|
|
28
|
+
* There is no plaintext option, and the manifest schema has no flag for one.
|
|
29
|
+
* IMAP's `LOGIN` puts the password on the wire in the clear, so a cleartext
|
|
30
|
+
* session is not a degraded mode — it is a credential disclosure, and offering
|
|
31
|
+
* it as configuration invites someone to reach for it while debugging.
|
|
32
|
+
*/
|
|
33
|
+
export const connectTls: SocketFactory = async (target) => {
|
|
34
|
+
const incoming: Uint8Array[] = [];
|
|
35
|
+
let waiting: ((chunk: Uint8Array | null) => void) | null = null;
|
|
36
|
+
let draining: (() => void) | null = null;
|
|
37
|
+
let ended = false;
|
|
38
|
+
let failure: Error | null = null;
|
|
39
|
+
|
|
40
|
+
const deliver = (chunk: Uint8Array | null): void => {
|
|
41
|
+
if (waiting) {
|
|
42
|
+
const resolve = waiting;
|
|
43
|
+
waiting = null;
|
|
44
|
+
resolve(chunk);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (chunk) incoming.push(chunk);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const socket = await Bun.connect({
|
|
51
|
+
hostname: target.host,
|
|
52
|
+
port: target.port,
|
|
53
|
+
tls: true,
|
|
54
|
+
socket: {
|
|
55
|
+
data(_socket, data) {
|
|
56
|
+
deliver(new Uint8Array(data));
|
|
57
|
+
},
|
|
58
|
+
drain() {
|
|
59
|
+
const resume = draining;
|
|
60
|
+
draining = null;
|
|
61
|
+
resume?.();
|
|
62
|
+
},
|
|
63
|
+
close() {
|
|
64
|
+
ended = true;
|
|
65
|
+
deliver(null);
|
|
66
|
+
},
|
|
67
|
+
error(_socket, error) {
|
|
68
|
+
failure = error;
|
|
69
|
+
ended = true;
|
|
70
|
+
deliver(null);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
async write(data) {
|
|
77
|
+
// Written in a loop because a large APPEND — saving a sent message back
|
|
78
|
+
// to the mailbox — will not fit in the socket's buffer in one go, and a
|
|
79
|
+
// short write that goes unnoticed truncates the message.
|
|
80
|
+
let offset = 0;
|
|
81
|
+
while (offset < data.length) {
|
|
82
|
+
const written = socket.write(data.subarray(offset));
|
|
83
|
+
if (written > 0) {
|
|
84
|
+
offset += written;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ended) throw failure ?? new Error('The connection closed while sending.');
|
|
88
|
+
await new Promise<void>((resolve) => {
|
|
89
|
+
draining = resolve;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async read() {
|
|
95
|
+
if (incoming.length > 0) return incoming.shift()!;
|
|
96
|
+
if (ended) {
|
|
97
|
+
if (failure) throw failure;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return new Promise<Uint8Array | null>((resolve) => {
|
|
101
|
+
waiting = resolve;
|
|
102
|
+
target.signal?.addEventListener('abort', () => resolve(null), { once: true });
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async close() {
|
|
107
|
+
ended = true;
|
|
108
|
+
socket.end();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
};
|