@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,398 @@
|
|
|
1
|
+
import { asText, ResponseAssembler, tokenize, type ImapToken } from './parser.ts';
|
|
2
|
+
import { connectTls, type ImapSocket, type SocketFactory } from './socket.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An IMAP session, and the policy for how long to keep one.
|
|
6
|
+
*
|
|
7
|
+
* One socket per connection, reused for a burst of tool calls, closed when idle.
|
|
8
|
+
* Not a pool: an IMAP session is *stateful* — the selected mailbox is session
|
|
9
|
+
* state — so a pool needs affinity or a re-SELECT per checkout, and its
|
|
10
|
+
* warm/cold divergence is exactly the thing ADR-002's statelessness asks us not
|
|
11
|
+
* to reason about. One socket has neither problem, and a cold instance simply
|
|
12
|
+
* opens one.
|
|
13
|
+
*
|
|
14
|
+
* Reused rather than opened per call because Apple throttles reconnection far
|
|
15
|
+
* harder than it throttles an open session, and a five-tool agent turn would
|
|
16
|
+
* otherwise be five TLS handshakes and five logins.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface ImapCredential {
|
|
20
|
+
readonly username: string;
|
|
21
|
+
readonly password: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ImapClientOptions {
|
|
25
|
+
readonly host: string;
|
|
26
|
+
readonly port: number;
|
|
27
|
+
readonly credential: () => Promise<ImapCredential>;
|
|
28
|
+
/** Injected in tests; defaults to implicit TLS over Bun's socket. */
|
|
29
|
+
readonly socket?: SocketFactory;
|
|
30
|
+
/** How long a session survives with nothing to do. */
|
|
31
|
+
readonly idleMs?: number;
|
|
32
|
+
readonly signal?: AbortSignal | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* One line to append when the server refuses the credential, in the
|
|
35
|
+
* provider's own words. Declared as `setup.troubleshooting`; this transport
|
|
36
|
+
* must not know which vendor it is talking to.
|
|
37
|
+
*/
|
|
38
|
+
readonly troubleshooting?: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CommandResult {
|
|
42
|
+
readonly status: 'OK' | 'NO' | 'BAD';
|
|
43
|
+
/** The text after the status word, which is what a server explains itself in. */
|
|
44
|
+
readonly text: string;
|
|
45
|
+
/** Every `*` response the command produced, already tokenised. */
|
|
46
|
+
readonly untagged: readonly ImapToken[][];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ImapError extends Error {
|
|
50
|
+
constructor(
|
|
51
|
+
message: string,
|
|
52
|
+
readonly status: 'NO' | 'BAD',
|
|
53
|
+
) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'ImapError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Strip anything secret from a line before it can reach a log or an error.
|
|
61
|
+
*
|
|
62
|
+
* The only real leak channel this connector has. A failed `LOGIN` is exactly
|
|
63
|
+
* when someone wants the trace, and the failing command carries the password —
|
|
64
|
+
* so redaction lives here rather than being remembered at each call site.
|
|
65
|
+
*/
|
|
66
|
+
export function redactTrace(line: string): string {
|
|
67
|
+
return line
|
|
68
|
+
.replace(/^(\s*\S+\s+LOGIN\s+\S+\s+)\S.*$/i, '$1***')
|
|
69
|
+
.replace(/^(\s*\S+\s+AUTHENTICATE\s+\S+\s+)\S.*$/i, '$1***');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Quote a string for the wire, escaping what IMAP says must be escaped.
|
|
74
|
+
*
|
|
75
|
+
* CR, LF and NUL are refused rather than escaped, because a quoted string has
|
|
76
|
+
* no way to carry them — RFC 3501 builds the grammar from QUOTED-CHAR, which
|
|
77
|
+
* excludes all three — and `exchange` writes a command as one CRLF-terminated
|
|
78
|
+
* line. Escaping only the backslash and the quote would let a newline end that
|
|
79
|
+
* line early, and the server would read what followed as the next command on an
|
|
80
|
+
* already-authenticated session. `searchCriteria` puts tool arguments through
|
|
81
|
+
* here, so that is reachable from a caller, not just from our own code.
|
|
82
|
+
*
|
|
83
|
+
* Refusing is the whole fix. The RFC's answer for arbitrary octets is a literal
|
|
84
|
+
* (`{n}` and then the bytes), but a literal needs a continuation round-trip and
|
|
85
|
+
* every call site here is a single-shot string — and nothing legitimate, no
|
|
86
|
+
* mailbox name and no search term, carries a bare CR.
|
|
87
|
+
*
|
|
88
|
+
* The value never reaches the message: one of the callers is `LOGIN`.
|
|
89
|
+
*/
|
|
90
|
+
export function quoted(value: string): string {
|
|
91
|
+
if (/[\r\n\0]/.test(value)) {
|
|
92
|
+
throw new Error('An IMAP string cannot contain a carriage return, newline, or NUL byte.');
|
|
93
|
+
}
|
|
94
|
+
return `"${value.replace(/([\\"])/g, '\\$1')}"`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ImapClient {
|
|
98
|
+
/**
|
|
99
|
+
* Run a unit of work against a live, authenticated session.
|
|
100
|
+
*
|
|
101
|
+
* Serialised: IMAP has one selected mailbox at a time, so two overlapping
|
|
102
|
+
* callers would silently read each other's folder.
|
|
103
|
+
*/
|
|
104
|
+
run<T>(work: (session: ImapSession) => Promise<T>, options?: { retry?: boolean }): Promise<T>;
|
|
105
|
+
close(): Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ImapSession {
|
|
109
|
+
readonly capabilities: ReadonlySet<string>;
|
|
110
|
+
readonly username: string;
|
|
111
|
+
command(text: string): Promise<CommandResult>;
|
|
112
|
+
/** Send a command whose argument is a literal — APPEND's message body. */
|
|
113
|
+
commandWithLiteral(prefix: string, literal: Uint8Array): Promise<CommandResult>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createImapClient(options: ImapClientOptions): ImapClient {
|
|
117
|
+
const connect = options.socket ?? connectTls;
|
|
118
|
+
const idleMs = options.idleMs ?? 60_000;
|
|
119
|
+
|
|
120
|
+
let session: Session | null = null;
|
|
121
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
122
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
123
|
+
|
|
124
|
+
const disarm = (): void => {
|
|
125
|
+
if (idleTimer !== undefined) clearTimeout(idleTimer);
|
|
126
|
+
idleTimer = undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const arm = (): void => {
|
|
130
|
+
disarm();
|
|
131
|
+
idleTimer = setTimeout(() => {
|
|
132
|
+
void shutdown();
|
|
133
|
+
}, idleMs);
|
|
134
|
+
// An idle timer must never be the reason the process stays alive: the CLI
|
|
135
|
+
// would print its result and then sit there for a minute.
|
|
136
|
+
idleTimer.unref?.();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const shutdown = async (): Promise<void> => {
|
|
140
|
+
disarm();
|
|
141
|
+
const closing = session;
|
|
142
|
+
session = null;
|
|
143
|
+
await closing?.close();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const ensure = async (): Promise<Session> => {
|
|
147
|
+
if (session) return session;
|
|
148
|
+
const { username, password } = await options.credential();
|
|
149
|
+
session = await openSession({ ...options, connect, username, password });
|
|
150
|
+
return session;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
run(work, runOptions) {
|
|
155
|
+
const result = queue.then(async () => {
|
|
156
|
+
disarm();
|
|
157
|
+
try {
|
|
158
|
+
try {
|
|
159
|
+
return await work(await ensure());
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// A server drops an idle connection without warning, so one
|
|
162
|
+
// transparent reopen is the difference between "works" and "works
|
|
163
|
+
// most of the time". Never more than once, and never for a write:
|
|
164
|
+
// a half-sent APPEND replayed would deliver the message twice.
|
|
165
|
+
if (runOptions?.retry === false || !isConnectionLost(error)) throw error;
|
|
166
|
+
await shutdown();
|
|
167
|
+
return await work(await ensure());
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
if (session) arm();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// The queue must not stop on a failure, or one bad call wedges every call
|
|
175
|
+
// after it.
|
|
176
|
+
queue = result.catch(() => undefined);
|
|
177
|
+
return result as Promise<never>;
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
close: shutdown,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isConnectionLost(error: unknown): boolean {
|
|
185
|
+
// A protocol-level NO/BAD is the server answering, not the socket dying —
|
|
186
|
+
// retrying it would just ask the same rejected question again.
|
|
187
|
+
if (error instanceof ImapError) return false;
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// One session
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
interface Session extends ImapSession {
|
|
196
|
+
close(): Promise<void>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function openSession(input: {
|
|
200
|
+
host: string;
|
|
201
|
+
port: number;
|
|
202
|
+
username: string;
|
|
203
|
+
password: string;
|
|
204
|
+
connect: SocketFactory;
|
|
205
|
+
signal?: AbortSignal | undefined;
|
|
206
|
+
troubleshooting?: string | undefined;
|
|
207
|
+
}): Promise<Session> {
|
|
208
|
+
const socket = await input.connect({
|
|
209
|
+
host: input.host,
|
|
210
|
+
port: input.port,
|
|
211
|
+
signal: input.signal,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const assembler = new ResponseAssembler();
|
|
215
|
+
const encoder = new TextEncoder();
|
|
216
|
+
let tag = 0;
|
|
217
|
+
let capabilities = new Set<string>();
|
|
218
|
+
|
|
219
|
+
const readResponse = async (): Promise<Uint8Array> => {
|
|
220
|
+
for (;;) {
|
|
221
|
+
const ready = assembler.next();
|
|
222
|
+
if (ready) return ready;
|
|
223
|
+
|
|
224
|
+
const chunk = await socket.read();
|
|
225
|
+
if (chunk === null) throw new Error('The IMAP connection closed unexpectedly.');
|
|
226
|
+
assembler.push(chunk);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Send a command and collect everything up to its tagged completion. */
|
|
231
|
+
const exchange = async (text: string, literal?: Uint8Array): Promise<CommandResult> => {
|
|
232
|
+
const label = `a${String(++tag).padStart(4, '0')}`;
|
|
233
|
+
const untagged: ImapToken[][] = [];
|
|
234
|
+
|
|
235
|
+
await socket.write(encoder.encode(`${label} ${text}\r\n`));
|
|
236
|
+
|
|
237
|
+
for (;;) {
|
|
238
|
+
const response = await readResponse();
|
|
239
|
+
const tokens = tokenize(response);
|
|
240
|
+
const first = asText(tokens[0]);
|
|
241
|
+
|
|
242
|
+
// A continuation (`+ `) means the server is ready for the literal it was
|
|
243
|
+
// told to expect. Only APPEND takes this path.
|
|
244
|
+
if (first === '+') {
|
|
245
|
+
if (literal) {
|
|
246
|
+
await socket.write(literal);
|
|
247
|
+
await socket.write(encoder.encode('\r\n'));
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (first === '*') {
|
|
253
|
+
untagged.push(tokens);
|
|
254
|
+
if (asText(tokens[1]) === 'OK') collectCapabilities(tokens, capabilities);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (first === label) {
|
|
259
|
+
const status = (asText(tokens[1]) ?? 'BAD') as CommandResult['status'];
|
|
260
|
+
const rest = tokens
|
|
261
|
+
.slice(2)
|
|
262
|
+
.map((token) => asText(token) ?? '')
|
|
263
|
+
.join(' ')
|
|
264
|
+
.trim();
|
|
265
|
+
return { status, text: rest, untagged };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// A tagged response for someone else cannot happen — commands are
|
|
269
|
+
// serialised — so this is a server we do not understand. Keep reading
|
|
270
|
+
// rather than hanging on a mismatch.
|
|
271
|
+
untagged.push(tokens);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const run = async (text: string, literal?: Uint8Array): Promise<CommandResult> => {
|
|
276
|
+
const result = await exchange(text, literal);
|
|
277
|
+
if (result.status !== 'OK') {
|
|
278
|
+
throw new ImapError(`${redactTrace(text)} → ${result.status} ${result.text}`, result.status);
|
|
279
|
+
}
|
|
280
|
+
return result;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// The greeting arrives unprompted and usually carries CAPABILITY, which saves
|
|
284
|
+
// a round trip — iCloud's does.
|
|
285
|
+
const greeting = tokenize(await readResponse());
|
|
286
|
+
collectCapabilities(greeting, capabilities);
|
|
287
|
+
|
|
288
|
+
if (capabilities.size === 0) {
|
|
289
|
+
const result = await run('CAPABILITY');
|
|
290
|
+
for (const tokens of result.untagged) collectCapabilities(tokens, capabilities);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await authenticate(run, capabilities, input.username, input.password, input.troubleshooting);
|
|
294
|
+
|
|
295
|
+
// Re-read: a server may advertise more once authenticated (iCloud adds MOVE).
|
|
296
|
+
const after = await run('CAPABILITY');
|
|
297
|
+
const refreshed = new Set<string>();
|
|
298
|
+
for (const tokens of after.untagged) collectCapabilities(tokens, refreshed);
|
|
299
|
+
if (refreshed.size > 0) capabilities = refreshed;
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
capabilities,
|
|
303
|
+
username: input.username,
|
|
304
|
+
command: (text) => run(text),
|
|
305
|
+
commandWithLiteral: (prefix, literal) => run(prefix, literal),
|
|
306
|
+
async close() {
|
|
307
|
+
// Announced but not awaited. LOGOUT is a courtesy — it lets the server
|
|
308
|
+
// release the mailbox rather than reaping a dropped connection — and its
|
|
309
|
+
// reply tells us nothing we can act on, since we are closing regardless.
|
|
310
|
+
// Waiting for one would let a server that never answers wedge shutdown.
|
|
311
|
+
try {
|
|
312
|
+
await socket.write(encoder.encode(`a${String(++tag).padStart(4, '0')} LOGOUT\r\n`));
|
|
313
|
+
} catch {
|
|
314
|
+
// Already gone, which is the outcome we wanted.
|
|
315
|
+
}
|
|
316
|
+
await socket.close();
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Log in, preferring SASL PLAIN.
|
|
323
|
+
*
|
|
324
|
+
* `AUTHENTICATE PLAIN` with an initial response (SASL-IR) sends the credential
|
|
325
|
+
* base64-encoded in one line, which sidesteps IMAP's string quoting entirely —
|
|
326
|
+
* `LOGIN` would need the password quoted and escaped, and a password containing
|
|
327
|
+
* a quote or a backslash is exactly the kind of thing that works in testing and
|
|
328
|
+
* fails for one unlucky person.
|
|
329
|
+
*/
|
|
330
|
+
async function authenticate(
|
|
331
|
+
run: (text: string) => Promise<CommandResult>,
|
|
332
|
+
capabilities: ReadonlySet<string>,
|
|
333
|
+
username: string,
|
|
334
|
+
password: string,
|
|
335
|
+
troubleshooting: string | undefined,
|
|
336
|
+
): Promise<void> {
|
|
337
|
+
const attempt = async (): Promise<void> => {
|
|
338
|
+
if (capabilities.has('SASL-IR') && capabilities.has('AUTH=PLAIN')) {
|
|
339
|
+
const initial = Buffer.from(`\0${username}\0${password}`, 'utf8').toString('base64');
|
|
340
|
+
await run(`AUTHENTICATE PLAIN ${initial}`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
await run(`LOGIN ${quoted(username)} ${quoted(password)}`);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await attempt();
|
|
348
|
+
} catch (error) {
|
|
349
|
+
throw asAuthenticationError(error, username, troubleshooting);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Translate a rejected login into the sentence that actually fixes it.
|
|
355
|
+
*
|
|
356
|
+
* The reason a login was refused is nearly always vendor-specific and never
|
|
357
|
+
* visible in what the server returns, so the *provider* supplies that sentence
|
|
358
|
+
* (`setup.troubleshooting`) and this only decides when to show it.
|
|
359
|
+
*/
|
|
360
|
+
function asAuthenticationError(
|
|
361
|
+
error: unknown,
|
|
362
|
+
username: string,
|
|
363
|
+
troubleshooting: string | undefined,
|
|
364
|
+
): Error {
|
|
365
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
366
|
+
if (!/AUTHENTICATIONFAILED|LOGIN failed|Invalid credentials|→ NO/i.test(message)) {
|
|
367
|
+
return error instanceof Error ? error : new Error(message);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return new Error(
|
|
371
|
+
`The mail server rejected the credential for ${username}.` +
|
|
372
|
+
(troubleshooting ? `\n ${troubleshooting}` : ''),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Pull `[CAPABILITY ...]` or an untagged `CAPABILITY` line into the set. */
|
|
377
|
+
function collectCapabilities(tokens: readonly ImapToken[], into: Set<string>): void {
|
|
378
|
+
for (const token of tokens) {
|
|
379
|
+
const text = asText(token);
|
|
380
|
+
if (!text) continue;
|
|
381
|
+
|
|
382
|
+
if (text.startsWith('[CAPABILITY ')) {
|
|
383
|
+
for (const name of text.slice('[CAPABILITY '.length, -1).split(/\s+/)) {
|
|
384
|
+
if (name) into.add(name.toUpperCase());
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (text === 'CAPABILITY') {
|
|
390
|
+
const start = tokens.indexOf(token) + 1;
|
|
391
|
+
for (const rest of tokens.slice(start)) {
|
|
392
|
+
const name = asText(rest);
|
|
393
|
+
if (name) into.add(name.toUpperCase());
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|