@pellux/goodvibes-agent 1.4.4 → 1.5.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/CHANGELOG.md +14 -0
- package/README.md +7 -7
- package/dist/package/main.js +7424 -12241
- package/docs/README.md +2 -2
- package/docs/channels-remote-and-api.md +1 -1
- package/docs/getting-started.md +2 -2
- package/docs/release-and-publishing.md +1 -1
- package/docs/tools-and-commands.md +5 -5
- package/package.json +4 -2
- package/release/performance-snapshot.json +2 -2
- package/release/release-notes.md +11 -7
- package/release/release-readiness.json +6 -6
- package/src/agent/behavior-discovery-summary.ts +4 -18
- package/src/agent/calendar-registry.ts +322 -0
- package/src/agent/competitive-feature-inventory.ts +268 -130
- package/src/agent/document-registry.ts +5 -1
- package/src/agent/email/email-service.ts +350 -0
- package/src/agent/email/imap-client.ts +596 -0
- package/src/agent/email/smtp-client.ts +453 -0
- package/src/agent/ics-calendar.ts +662 -0
- package/src/agent/ics-timezone.ts +172 -0
- package/src/agent/markdown-frontmatter.ts +31 -0
- package/src/agent/memory-safety.ts +1 -1
- package/src/agent/note-registry.ts +3 -9
- package/src/agent/persona-discovery.ts +3 -15
- package/src/agent/persona-registry.ts +1 -10
- package/src/agent/research-source-registry.ts +5 -1
- package/src/agent/routine-discovery.ts +3 -15
- package/src/agent/runtime-profile.ts +3 -0
- package/src/agent/skill-discovery.ts +3 -15
- package/src/agent/skill-draft-proposer.ts +203 -0
- package/src/agent/skill-draft-runner.ts +201 -0
- package/src/agent/skill-registry.ts +36 -1
- package/src/agent/skill-standard.ts +99 -0
- package/src/agent/vibe-file.ts +7 -22
- package/src/cli/completion.ts +1 -1
- package/src/cli/config-overrides.ts +10 -1
- package/src/cli/redaction.ts +17 -5
- package/src/config/provider-model.ts +2 -1
- package/src/config/secret-config.ts +13 -6
- package/src/core/activity-feed.ts +97 -0
- package/src/core/away-digest.ts +161 -0
- package/src/core/conversation-rendering.ts +7 -3
- package/src/core/conversation.ts +22 -15
- package/src/core/hardware-profile.ts +362 -0
- package/src/core/last-seen-store.ts +78 -0
- package/src/core/plain-language.ts +52 -0
- package/src/core/setup-incomplete-hint.ts +90 -0
- package/src/core/system-message-router.ts +38 -87
- package/src/input/agent-workspace-basic-command-editor-submission.ts +60 -220
- package/src/input/agent-workspace-basic-command-editors.ts +39 -141
- package/src/input/agent-workspace-categories.ts +40 -125
- package/src/input/agent-workspace-command-editor.ts +4 -0
- package/src/input/agent-workspace-host-category.ts +0 -5
- package/src/input/agent-workspace-local-editor-submission.ts +3 -1
- package/src/input/agent-workspace-navigation.ts +10 -3
- package/src/input/agent-workspace-onboarding-actions.ts +132 -0
- package/src/input/agent-workspace-onboarding-categories.ts +29 -26
- package/src/input/agent-workspace-onboarding-finish.ts +11 -4
- package/src/input/agent-workspace-onboarding-state.ts +111 -0
- package/src/input/agent-workspace-profile-editor-submission.ts +260 -0
- package/src/input/agent-workspace-profile-editors.ts +155 -0
- package/src/input/agent-workspace-subscription-editor.ts +7 -0
- package/src/input/agent-workspace-types.ts +5 -1
- package/src/input/agent-workspace.ts +65 -39
- package/src/input/command-registry.ts +18 -5
- package/src/input/commands/agent-runtime-profile-runtime.ts +2 -1
- package/src/input/commands/agent-skills-runtime.ts +60 -3
- package/src/input/commands/calendar-runtime.ts +209 -0
- package/src/input/commands/channels-runtime.ts +1 -0
- package/src/input/commands/command-error.ts +9 -0
- package/src/input/commands/compat-runtime.ts +1 -0
- package/src/input/commands/config.ts +1 -0
- package/src/input/commands/conversation-runtime.ts +1 -1
- package/src/input/commands/delegation-runtime.ts +5 -6
- package/src/input/commands/email-runtime.ts +387 -0
- package/src/input/commands/experience-runtime.ts +17 -4
- package/src/input/commands/guidance-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +6 -1
- package/src/input/commands/knowledge-format.ts +73 -0
- package/src/input/commands/knowledge.ts +9 -74
- package/src/input/commands/local-provider-runtime.ts +2 -1
- package/src/input/commands/local-runtime.ts +10 -4
- package/src/input/commands/mcp-runtime.ts +7 -0
- package/src/input/commands/notify-runtime.ts +17 -1
- package/src/input/commands/onboarding-runtime.ts +1 -0
- package/src/input/commands/operator-actions-runtime.ts +1 -0
- package/src/input/commands/operator-runtime.ts +3 -0
- package/src/input/commands/personas-runtime.ts +1 -0
- package/src/input/commands/platform-access-runtime.ts +40 -17
- package/src/input/commands/product-runtime.ts +6 -1
- package/src/input/commands/provider-accounts-runtime.ts +3 -2
- package/src/input/commands/qrcode-runtime.ts +1 -0
- package/src/input/commands/routines-runtime.ts +1 -0
- package/src/input/commands/runtime-services.ts +0 -13
- package/src/input/commands/security-runtime.ts +1 -0
- package/src/input/commands/session-content.ts +1 -0
- package/src/input/commands/shell-core.ts +5 -2
- package/src/input/commands/subscription-runtime.ts +33 -9
- package/src/input/commands/support-bundle-runtime.ts +8 -1
- package/src/input/commands/tasks-runtime.ts +1 -0
- package/src/input/commands/tts-runtime.ts +1 -0
- package/src/input/commands/vibe-runtime.ts +1 -0
- package/src/input/commands.ts +4 -0
- package/src/input/feed-context-factory.ts +3 -12
- package/src/input/handler-command-route.ts +7 -60
- package/src/input/handler-feed-routes.ts +0 -194
- package/src/input/handler-feed.ts +2 -54
- package/src/input/handler-interactions.ts +0 -2
- package/src/input/handler-modal-stack.ts +0 -9
- package/src/input/handler-picker-routes.ts +24 -1
- package/src/input/handler-shortcuts.ts +11 -40
- package/src/input/handler-ui-state.ts +13 -0
- package/src/input/handler.ts +8 -35
- package/src/input/keybindings.ts +40 -17
- package/src/input/model-picker-local-fit.ts +130 -0
- package/src/input/submission-router.ts +0 -5
- package/src/main.ts +111 -89
- package/src/renderer/activity-sidebar.ts +186 -0
- package/src/renderer/agent-workspace-context-lines.ts +5 -48
- package/src/renderer/agent-workspace-style.ts +1 -1
- package/src/renderer/agent-workspace.ts +14 -2
- package/src/renderer/compositor.ts +37 -125
- package/src/renderer/conversation-overlays.ts +3 -3
- package/src/renderer/help-overlay.ts +7 -5
- package/src/renderer/model-workspace.ts +101 -86
- package/src/{panels → renderer}/polish.ts +3 -3
- package/src/renderer/shell-surface.ts +4 -5
- package/src/renderer/status-token.ts +31 -11
- package/src/renderer/tab-strip.ts +1 -1
- package/src/renderer/tool-call.ts +7 -8
- package/src/renderer/tool-labels.ts +92 -0
- package/src/renderer/ui-factory.ts +48 -96
- package/src/runtime/bootstrap-command-context.ts +0 -5
- package/src/runtime/bootstrap-command-parts.ts +6 -15
- package/src/runtime/bootstrap-core.ts +34 -13
- package/src/runtime/bootstrap-hook-bridge.ts +3 -1
- package/src/runtime/bootstrap-shell.ts +3 -50
- package/src/runtime/bootstrap.ts +3 -4
- package/src/runtime/context.ts +1 -1
- package/src/runtime/diagnostics/panels/index.ts +0 -1
- package/src/runtime/diagnostics/panels/policy.ts +1 -1
- package/src/runtime/execution-ledger.ts +16 -1
- package/src/runtime/index.ts +6 -1
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/onboarding-state.ts +200 -0
- package/src/{panels → runtime}/provider-account-snapshot.ts +5 -2
- package/src/runtime/services.ts +16 -4
- package/src/runtime/terminal-output-guard.ts +7 -0
- package/src/runtime/tool-permission-safety.ts +1 -1
- package/src/runtime/ui/model-picker/data-provider.ts +1 -1
- package/src/runtime/ui/model-picker/health-enrichment.ts +2 -2
- package/src/runtime/ui/model-picker/types.ts +2 -2
- package/src/runtime/ui/provider-health/data-provider.ts +3 -3
- package/src/runtime/ui/provider-health/types.ts +2 -2
- package/src/runtime/ui-services.ts +0 -2
- package/src/shell/agent-workspace-fullscreen.ts +0 -1
- package/src/shell/autonomy-surfacing.ts +272 -0
- package/src/shell/session-continuity-hints.ts +0 -6
- package/src/shell/startup-wiring.ts +221 -0
- package/src/shell/ui-openers.ts +18 -29
- package/src/tools/agent-context-policy.ts +1 -1
- package/src/tools/agent-harness-background-processes.ts +4 -4
- package/src/tools/agent-harness-browser-cockpit-route.ts +3 -3
- package/src/tools/agent-harness-command-runner.ts +6 -1
- package/src/tools/agent-harness-document-ops.ts +26 -53
- package/src/tools/agent-harness-execution-history.ts +1 -13
- package/src/tools/agent-harness-execution-posture.ts +0 -15
- package/src/tools/agent-harness-keybinding-metadata.ts +28 -29
- package/src/tools/agent-harness-local-model-cookbook.ts +24 -1
- package/src/tools/agent-harness-metadata.ts +16 -0
- package/src/tools/agent-harness-mode-catalog.ts +2 -9
- package/src/tools/agent-harness-provider-account-metadata.ts +2 -2
- package/src/tools/agent-harness-setup-posture-utils.ts +1 -1
- package/src/tools/agent-harness-tool-schema.ts +13 -14
- package/src/tools/agent-harness-tool.ts +27 -23
- package/src/tools/agent-harness-ui-surface-metadata.ts +10 -20
- package/src/tools/agent-harness-workspace-action-runner.ts +3 -4
- package/src/tools/agent-workspace-tool.ts +2 -33
- package/src/utils/terminal-width.ts +9 -3
- package/src/version.ts +1 -1
- package/src/input/agent-workspace-panel-route.ts +0 -44
- package/src/input/panel-integration-actions.ts +0 -26
- package/src/panels/approval-panel.ts +0 -149
- package/src/panels/automation-control-panel.ts +0 -212
- package/src/panels/base-panel.ts +0 -254
- package/src/panels/builtin/agent.ts +0 -58
- package/src/panels/builtin/knowledge.ts +0 -26
- package/src/panels/builtin/operations.ts +0 -121
- package/src/panels/builtin/session.ts +0 -138
- package/src/panels/builtin/shared.ts +0 -275
- package/src/panels/builtin/usage.ts +0 -21
- package/src/panels/builtin-panels.ts +0 -23
- package/src/panels/confirm-state.ts +0 -61
- package/src/panels/context-visualizer-panel.ts +0 -204
- package/src/panels/cost-tracker-panel.ts +0 -444
- package/src/panels/docs-panel.ts +0 -285
- package/src/panels/index.ts +0 -25
- package/src/panels/knowledge-panel.ts +0 -417
- package/src/panels/memory-panel.ts +0 -226
- package/src/panels/panel-list-panel.ts +0 -464
- package/src/panels/panel-manager.ts +0 -570
- package/src/panels/provider-accounts-panel.ts +0 -233
- package/src/panels/provider-health-domains.ts +0 -208
- package/src/panels/provider-health-panel.ts +0 -720
- package/src/panels/provider-health-tracker.ts +0 -115
- package/src/panels/provider-stats-panel.ts +0 -366
- package/src/panels/qr-panel.ts +0 -207
- package/src/panels/schedule-panel.ts +0 -321
- package/src/panels/scrollable-list-panel.ts +0 -491
- package/src/panels/search-focus.ts +0 -32
- package/src/panels/security-panel.ts +0 -295
- package/src/panels/session-browser-panel.ts +0 -395
- package/src/panels/session-maintenance.ts +0 -125
- package/src/panels/subscription-panel.ts +0 -263
- package/src/panels/system-messages-panel.ts +0 -230
- package/src/panels/tasks-panel.ts +0 -344
- package/src/panels/thinking-panel.ts +0 -304
- package/src/panels/token-budget-panel.ts +0 -475
- package/src/panels/tool-inspector-panel.ts +0 -436
- package/src/panels/types.ts +0 -54
- package/src/renderer/panel-composite.ts +0 -158
- package/src/renderer/panel-tab-bar.ts +0 -69
- package/src/renderer/panel-workspace-bar.ts +0 -42
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/tools/agent-harness-panel-metadata.ts +0 -211
- /package/src/runtime/perf/{panel-health-monitor.ts → component-health.ts} +0 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal IMAP4rev1 client over an injectable transport socket.
|
|
3
|
+
*
|
|
4
|
+
* Scope and honest boundaries
|
|
5
|
+
* ────────────────────────────
|
|
6
|
+
* Supported:
|
|
7
|
+
* - LOGIN with plain credentials (tag AUTH LOGIN user pass)
|
|
8
|
+
* - EXAMINE INBOX (read-only SELECT; messages are never marked \Seen)
|
|
9
|
+
* - SEARCH UNSEEN and SEARCH SINCE <date>
|
|
10
|
+
* - FETCH BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID)] — envelope
|
|
11
|
+
* - FETCH BODY.PEEK[TEXT]<0.N> — bounded plain-text body preview
|
|
12
|
+
* - XOAUTH2 pass-through: if imapPassword starts with 'Bearer ' the client
|
|
13
|
+
* sends AUTHENTICATE XOAUTH2 with the base64-encoded SASL token; token
|
|
14
|
+
* acquisition is out of scope.
|
|
15
|
+
* - {n} literal continuations on server responses
|
|
16
|
+
* - Per-await timeouts via AbortSignal
|
|
17
|
+
* - LOGOUT
|
|
18
|
+
*
|
|
19
|
+
* Not supported (document boundaries):
|
|
20
|
+
* - IDLE / NOTIFY push
|
|
21
|
+
* - STARTTLS upgrade (use TLS-direct port 993)
|
|
22
|
+
* - Full MIME multipart decoding; only the first raw text block is returned
|
|
23
|
+
* - Message UID operations (uses sequence numbers)
|
|
24
|
+
* - APPEND, COPY, MOVE, EXPUNGE
|
|
25
|
+
* - Credentials are never logged; callers must not log them either
|
|
26
|
+
*
|
|
27
|
+
* Transport injection
|
|
28
|
+
* ────────────────────
|
|
29
|
+
* Accept a `SocketLike` instead of creating a TLS socket directly so that
|
|
30
|
+
* unit tests can supply a plain net.Socket connected to an in-process fake
|
|
31
|
+
* server. Production callers pass the result of `createImapTlsSocket()`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { Socket } from 'node:net';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Public types
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export interface ImapEnvelope {
|
|
41
|
+
readonly uid: number;
|
|
42
|
+
readonly from: string;
|
|
43
|
+
readonly subject: string;
|
|
44
|
+
readonly date: string;
|
|
45
|
+
readonly messageId: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ImapMessage extends ImapEnvelope {
|
|
49
|
+
readonly bodyPreview: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ImapClientOptions {
|
|
53
|
+
/** Pre-connected socket (TLS for prod, plain for tests). */
|
|
54
|
+
readonly socket: Socket;
|
|
55
|
+
/** IMAP LOGIN username. */
|
|
56
|
+
readonly username: string;
|
|
57
|
+
/** IMAP LOGIN password or 'Bearer <token>' for XOAUTH2 pass-through. */
|
|
58
|
+
readonly password: string;
|
|
59
|
+
/** Per-operation timeout in milliseconds. Default: 15 000. */
|
|
60
|
+
readonly timeoutMs?: number;
|
|
61
|
+
/** Maximum body preview bytes to fetch. Default: 4096. */
|
|
62
|
+
readonly maxBodyBytes?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Internal helpers
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
70
|
+
const DEFAULT_MAX_BODY_BYTES = 4_096;
|
|
71
|
+
const CRLF = '\r\n';
|
|
72
|
+
|
|
73
|
+
function buildXOAuth2Token(username: string, bearerToken: string): string {
|
|
74
|
+
const sasl = `user=${username}\x01auth=${bearerToken}\x01\x01`;
|
|
75
|
+
return Buffer.from(sasl).toString('base64');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wraps a Socket with line-buffered async reading and tagged command writing.
|
|
80
|
+
* Owns a single shared read cursor; callers must not interleave awaits.
|
|
81
|
+
*/
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// IMAP credential quoting (SEC-2: LOGIN injection prevention)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reject credentials containing CR or LF — these cannot be safely represented
|
|
88
|
+
* in any IMAP quoted-string or literal.
|
|
89
|
+
* Then return the credential as an RFC 3501 quoted string:
|
|
90
|
+
* - backslashes escaped as \\\\
|
|
91
|
+
* - double-quotes escaped as \\"
|
|
92
|
+
* If the result would contain characters outside of printable US-ASCII
|
|
93
|
+
* (which quoted strings cannot hold per RFC 3501), throw a plain-language error.
|
|
94
|
+
*/
|
|
95
|
+
export function imapQuoteCredential(value: string, name: string): string {
|
|
96
|
+
if (/[\r\n]/.test(value)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Invalid IMAP ${name}: credentials must not contain carriage return or newline characters.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
// RFC 3501 quoted-string is 7-bit only: only printable US-ASCII (0x20–0x7E) is
|
|
102
|
+
// allowed. Reject anything outside that range — control chars (0x00–0x1F, 0x7F)
|
|
103
|
+
// and 8-bit bytes (0x80–0xFF) both produce malformed wire data.
|
|
104
|
+
if (/[^\x20-\x7e]/.test(value)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Invalid IMAP ${name}: credentials must be printable US-ASCII characters; 8-bit or control characters aren't supported.`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
// Escape backslash and double-quote per RFC 3501 §4.3
|
|
110
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
111
|
+
return `"${escaped}"`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class ImapSession {
|
|
115
|
+
private readonly socket: Socket;
|
|
116
|
+
private readonly timeoutMs: number;
|
|
117
|
+
private readonly literalCap: number;
|
|
118
|
+
private buffer = '';
|
|
119
|
+
private tagCounter = 0;
|
|
120
|
+
|
|
121
|
+
constructor(socket: Socket, timeoutMs: number, literalCap: number) {
|
|
122
|
+
this.socket = socket;
|
|
123
|
+
this.timeoutMs = timeoutMs;
|
|
124
|
+
this.literalCap = literalCap;
|
|
125
|
+
this.socket.setEncoding('utf8');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// -------------------------------------------------------------------------
|
|
129
|
+
// Low-level I/O
|
|
130
|
+
// -------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/** Read lines until the predicate returns the accumulated lines or null. */
|
|
133
|
+
private async readUntil(
|
|
134
|
+
predicate: (lines: string[]) => string[] | null,
|
|
135
|
+
): Promise<string[]> {
|
|
136
|
+
const lines: string[] = [];
|
|
137
|
+
|
|
138
|
+
return new Promise<string[]>((resolve, reject) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
cleanup();
|
|
141
|
+
reject(new Error('IMAP read timeout'));
|
|
142
|
+
}, this.timeoutMs);
|
|
143
|
+
|
|
144
|
+
const onData = (chunk: string): void => {
|
|
145
|
+
this.buffer += chunk;
|
|
146
|
+
let pos: number;
|
|
147
|
+
while ((pos = this.buffer.indexOf('\n')) !== -1) {
|
|
148
|
+
const line = this.buffer.slice(0, pos).replace(/\r$/, '');
|
|
149
|
+
this.buffer = this.buffer.slice(pos + 1);
|
|
150
|
+
lines.push(line);
|
|
151
|
+
const result = predicate(lines);
|
|
152
|
+
if (result !== null) {
|
|
153
|
+
cleanup();
|
|
154
|
+
resolve(result);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const onError = (err: Error): void => {
|
|
161
|
+
cleanup();
|
|
162
|
+
reject(err);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const onClose = (): void => {
|
|
166
|
+
cleanup();
|
|
167
|
+
reject(new Error('IMAP connection closed unexpectedly'));
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const cleanup = (): void => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
this.socket.off('data', onData);
|
|
173
|
+
this.socket.off('error', onError);
|
|
174
|
+
this.socket.off('close', onClose);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
this.socket.on('data', onData);
|
|
178
|
+
this.socket.on('error', onError);
|
|
179
|
+
this.socket.on('close', onClose);
|
|
180
|
+
|
|
181
|
+
// Flush any already-buffered data
|
|
182
|
+
if (this.buffer.length > 0) {
|
|
183
|
+
onData('');
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Read the server greeting. */
|
|
189
|
+
async readGreeting(): Promise<void> {
|
|
190
|
+
await this.readUntil((lines) => {
|
|
191
|
+
const last = lines[lines.length - 1] ?? '';
|
|
192
|
+
if (last.startsWith('* OK') || last.startsWith('* PREAUTH')) return lines;
|
|
193
|
+
if (last.startsWith('* BYE')) return lines; // rejected
|
|
194
|
+
return null;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Send a tagged IMAP command and collect all response lines through completion. */
|
|
199
|
+
async command(text: string): Promise<string[]> {
|
|
200
|
+
this.tagCounter += 1;
|
|
201
|
+
const tag = `A${String(this.tagCounter).padStart(4, '0')}`;
|
|
202
|
+
const raw = `${tag} ${text}${CRLF}`;
|
|
203
|
+
|
|
204
|
+
await this.write(raw);
|
|
205
|
+
return this.readTaggedResponse(tag);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Collect response lines for a tagged command, handling {n} literals.
|
|
210
|
+
* A literal is signalled by a server response line ending with {<n>}.
|
|
211
|
+
* We read exactly n bytes of literal data then continue line reading.
|
|
212
|
+
*/
|
|
213
|
+
private async readTaggedResponse(tag: string): Promise<string[]> {
|
|
214
|
+
const lines: string[] = [];
|
|
215
|
+
|
|
216
|
+
return new Promise<string[]>((resolve, reject) => {
|
|
217
|
+
let literalBytesRemaining = 0;
|
|
218
|
+
let literalAccum = '';
|
|
219
|
+
let literalOwnerLine = '';
|
|
220
|
+
const timer = setTimeout(() => {
|
|
221
|
+
cleanup();
|
|
222
|
+
reject(new Error(`IMAP command ${tag} timed out`));
|
|
223
|
+
}, this.timeoutMs);
|
|
224
|
+
|
|
225
|
+
const flush = (): void => {
|
|
226
|
+
let pos: number;
|
|
227
|
+
while (this.buffer.length > 0) {
|
|
228
|
+
if (literalBytesRemaining > 0) {
|
|
229
|
+
const take = Math.min(literalBytesRemaining, this.buffer.length);
|
|
230
|
+
literalAccum += this.buffer.slice(0, take);
|
|
231
|
+
this.buffer = this.buffer.slice(take);
|
|
232
|
+
literalBytesRemaining -= take;
|
|
233
|
+
if (literalBytesRemaining === 0) {
|
|
234
|
+
lines.push(`${literalOwnerLine}${literalAccum}`);
|
|
235
|
+
literalAccum = '';
|
|
236
|
+
literalOwnerLine = '';
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
pos = this.buffer.indexOf('\n');
|
|
242
|
+
if (pos === -1) break;
|
|
243
|
+
|
|
244
|
+
const line = this.buffer.slice(0, pos).replace(/\r$/, '');
|
|
245
|
+
this.buffer = this.buffer.slice(pos + 1);
|
|
246
|
+
|
|
247
|
+
// Check for literal continuation
|
|
248
|
+
const literalMatch = /\{(\d+)\}$/.exec(line);
|
|
249
|
+
if (literalMatch) {
|
|
250
|
+
const requested = parseInt(literalMatch[1] ?? '0', 10);
|
|
251
|
+
// SEC-3: cap server-supplied literal size to prevent memory DoS
|
|
252
|
+
if (requested > this.literalCap) {
|
|
253
|
+
cleanup();
|
|
254
|
+
reject(new Error(
|
|
255
|
+
`IMAP server sent an oversized literal ({${requested}} bytes, ` +
|
|
256
|
+
`max allowed: ${this.literalCap}). The operation has been aborted.`,
|
|
257
|
+
));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
literalBytesRemaining = requested;
|
|
261
|
+
literalOwnerLine = line.slice(0, line.lastIndexOf('{')) + ' ';
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
lines.push(line);
|
|
266
|
+
|
|
267
|
+
// Tagged completion
|
|
268
|
+
if (line.startsWith(`${tag} OK`) || line.startsWith(`${tag} NO`) || line.startsWith(`${tag} BAD`)) {
|
|
269
|
+
cleanup();
|
|
270
|
+
if (line.startsWith(`${tag} NO`) || line.startsWith(`${tag} BAD`)) {
|
|
271
|
+
reject(new Error(`IMAP command failed: ${line}`));
|
|
272
|
+
} else {
|
|
273
|
+
resolve(lines);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const onData = (chunk: string): void => {
|
|
281
|
+
this.buffer += chunk;
|
|
282
|
+
flush();
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const onError = (err: Error): void => {
|
|
286
|
+
cleanup();
|
|
287
|
+
reject(err);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const onClose = (): void => {
|
|
291
|
+
cleanup();
|
|
292
|
+
reject(new Error('IMAP connection closed during command'));
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const cleanup = (): void => {
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
this.socket.off('data', onData);
|
|
298
|
+
this.socket.off('error', onError);
|
|
299
|
+
this.socket.off('close', onClose);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
this.socket.on('data', onData);
|
|
303
|
+
this.socket.on('error', onError);
|
|
304
|
+
this.socket.on('close', onClose);
|
|
305
|
+
|
|
306
|
+
// Flush already-buffered data
|
|
307
|
+
flush();
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private write(data: string): Promise<void> {
|
|
312
|
+
return new Promise<void>((resolve, reject) => {
|
|
313
|
+
this.socket.write(data, 'utf8', (err) => {
|
|
314
|
+
if (err) reject(err);
|
|
315
|
+
else resolve();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
destroy(): void {
|
|
321
|
+
try {
|
|
322
|
+
this.socket.destroy();
|
|
323
|
+
} catch {
|
|
324
|
+
// ignore
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Header parsing helpers
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
function extractHeader(rawHeaders: string, name: string): string {
|
|
334
|
+
const lower = rawHeaders.toLowerCase();
|
|
335
|
+
const needle = `\n${name.toLowerCase()}:`;
|
|
336
|
+
let start = lower.indexOf(needle);
|
|
337
|
+
if (start === -1) {
|
|
338
|
+
// Check beginning of string too
|
|
339
|
+
if (lower.startsWith(`${name.toLowerCase()}:`)) {
|
|
340
|
+
start = -1 * name.length; // will handle below
|
|
341
|
+
} else {
|
|
342
|
+
return '';
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const valueStart = start === -1
|
|
346
|
+
? name.length + 1
|
|
347
|
+
: start + needle.length;
|
|
348
|
+
const rawSlice = rawHeaders.slice(valueStart);
|
|
349
|
+
// Fold continuation lines (RFC 2822 folding)
|
|
350
|
+
const end = rawSlice.search(/\n(?![\t ])/);
|
|
351
|
+
const raw = end === -1 ? rawSlice : rawSlice.slice(0, end);
|
|
352
|
+
return raw.replace(/\r?\n[\t ]+/g, ' ').trim();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function parseSequenceNumbers(searchResponse: readonly string[]): number[] {
|
|
356
|
+
const nums: number[] = [];
|
|
357
|
+
for (const line of searchResponse) {
|
|
358
|
+
if (!line.startsWith('* SEARCH')) continue;
|
|
359
|
+
const parts = line.slice(9).trim().split(/\s+/);
|
|
360
|
+
for (const part of parts) {
|
|
361
|
+
const n = parseInt(part, 10);
|
|
362
|
+
if (!isNaN(n) && n > 0) nums.push(n);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return nums;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function parseFetchHeaders(fetchLines: readonly string[]): Record<number, string> {
|
|
369
|
+
const result: Record<number, string> = {};
|
|
370
|
+
let seqNum = 0;
|
|
371
|
+
let headerAccum = '';
|
|
372
|
+
let inBody = false;
|
|
373
|
+
|
|
374
|
+
for (const line of fetchLines) {
|
|
375
|
+
const fetchStart = /^\* (\d+) FETCH/.exec(line);
|
|
376
|
+
if (fetchStart) {
|
|
377
|
+
seqNum = parseInt(fetchStart[1] ?? '0', 10);
|
|
378
|
+
headerAccum = '';
|
|
379
|
+
inBody = true;
|
|
380
|
+
// The literal content may be appended after the header-fields marker on the same line
|
|
381
|
+
const afterFetch = line.slice(fetchStart[0].length);
|
|
382
|
+
const literalAfter = afterFetch.indexOf(' ') !== -1 ? afterFetch.slice(afterFetch.indexOf(' ') + 1) : '';
|
|
383
|
+
if (literalAfter && !literalAfter.startsWith('(')) {
|
|
384
|
+
headerAccum += literalAfter + '\n';
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (inBody) {
|
|
389
|
+
if (line === ')') {
|
|
390
|
+
if (seqNum > 0) result[seqNum] = headerAccum;
|
|
391
|
+
inBody = false;
|
|
392
|
+
seqNum = 0;
|
|
393
|
+
headerAccum = '';
|
|
394
|
+
} else {
|
|
395
|
+
headerAccum += line + '\n';
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function parseFetchBody(fetchLines: readonly string[]): Record<number, string> {
|
|
403
|
+
const result: Record<number, string> = {};
|
|
404
|
+
let seqNum = 0;
|
|
405
|
+
let bodyAccum = '';
|
|
406
|
+
let inBody = false;
|
|
407
|
+
|
|
408
|
+
for (const line of fetchLines) {
|
|
409
|
+
const fetchStart = /^\* (\d+) FETCH/.exec(line);
|
|
410
|
+
if (fetchStart) {
|
|
411
|
+
seqNum = parseInt(fetchStart[1] ?? '0', 10);
|
|
412
|
+
bodyAccum = '';
|
|
413
|
+
inBody = true;
|
|
414
|
+
// literal content appended after BODY.PEEK[TEXT] tag
|
|
415
|
+
const afterLiteral = line.slice(line.indexOf(' ', fetchStart[0].length) + 1);
|
|
416
|
+
if (afterLiteral && !afterLiteral.includes('BODY')) {
|
|
417
|
+
bodyAccum += afterLiteral + '\n';
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (inBody) {
|
|
422
|
+
if (line === ')') {
|
|
423
|
+
if (seqNum > 0) result[seqNum] = bodyAccum;
|
|
424
|
+
inBody = false;
|
|
425
|
+
seqNum = 0;
|
|
426
|
+
bodyAccum = '';
|
|
427
|
+
} else {
|
|
428
|
+
bodyAccum += line + '\n';
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Formatted date for SINCE queries
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
const IMAP_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
440
|
+
|
|
441
|
+
export function formatImapDate(date: Date): string {
|
|
442
|
+
const d = date.getDate();
|
|
443
|
+
const m = IMAP_MONTHS[date.getMonth()] ?? 'Jan';
|
|
444
|
+
const y = date.getFullYear();
|
|
445
|
+
return `${d}-${m}-${y}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Public client
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
export class ImapClient {
|
|
453
|
+
private readonly options: ImapClientOptions;
|
|
454
|
+
|
|
455
|
+
constructor(options: ImapClientOptions) {
|
|
456
|
+
this.options = options;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Connect and authenticate. Must be called before any fetch operations.
|
|
461
|
+
* Uses EXAMINE (read-only) so messages are never marked \Seen.
|
|
462
|
+
*/
|
|
463
|
+
async open(): Promise<void> {
|
|
464
|
+
const session = this.session();
|
|
465
|
+
await session.readGreeting();
|
|
466
|
+
await this.authenticate(session);
|
|
467
|
+
await session.command('EXAMINE INBOX');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Search for unseen messages. Returns sequence numbers.
|
|
472
|
+
* Pass sinceDate to restrict to messages since a date.
|
|
473
|
+
*/
|
|
474
|
+
async searchUnseen(sinceDate?: Date): Promise<number[]> {
|
|
475
|
+
const session = this.session();
|
|
476
|
+
const criterion = sinceDate
|
|
477
|
+
? `UNSEEN SINCE ${formatImapDate(sinceDate)}`
|
|
478
|
+
: 'UNSEEN';
|
|
479
|
+
const lines = await session.command(`SEARCH ${criterion}`);
|
|
480
|
+
return parseSequenceNumbers(lines);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Fetch envelope headers for an array of sequence numbers.
|
|
485
|
+
* Uses BODY.PEEK so messages remain unread.
|
|
486
|
+
* Returns at most `limit` messages (most recent first, approximate).
|
|
487
|
+
*/
|
|
488
|
+
async fetchEnvelopes(seqNums: readonly number[], limit = 20): Promise<ImapEnvelope[]> {
|
|
489
|
+
if (seqNums.length === 0) return [];
|
|
490
|
+
const session = this.session();
|
|
491
|
+
const bounded = seqNums.slice(-limit); // take the last N (highest seq = newest)
|
|
492
|
+
const set = bounded.join(',');
|
|
493
|
+
const lines = await session.command(
|
|
494
|
+
`FETCH ${set} BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID)]`,
|
|
495
|
+
);
|
|
496
|
+
const headersMap = parseFetchHeaders(lines);
|
|
497
|
+
const envelopes: ImapEnvelope[] = [];
|
|
498
|
+
for (const seqNum of bounded) {
|
|
499
|
+
const raw = headersMap[seqNum] ?? '';
|
|
500
|
+
envelopes.push({
|
|
501
|
+
uid: seqNum,
|
|
502
|
+
from: extractHeader(raw, 'From'),
|
|
503
|
+
subject: extractHeader(raw, 'Subject'),
|
|
504
|
+
date: extractHeader(raw, 'Date'),
|
|
505
|
+
messageId: extractHeader(raw, 'Message-ID'),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return envelopes;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Fetch a bounded body preview for a single message.
|
|
513
|
+
* Uses BODY.PEEK[TEXT]<0.N> so message stays unread.
|
|
514
|
+
*/
|
|
515
|
+
async fetchBodyPreview(seqNum: number): Promise<string> {
|
|
516
|
+
const session = this.session();
|
|
517
|
+
const maxBytes = this.options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
518
|
+
const lines = await session.command(
|
|
519
|
+
`FETCH ${seqNum} BODY.PEEK[TEXT]<0.${maxBytes}>`,
|
|
520
|
+
);
|
|
521
|
+
const bodyMap = parseFetchBody(lines);
|
|
522
|
+
return (bodyMap[seqNum] ?? '').slice(0, maxBytes);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Send LOGOUT and destroy the socket. */
|
|
526
|
+
async logout(): Promise<void> {
|
|
527
|
+
const session = this.session();
|
|
528
|
+
try {
|
|
529
|
+
await session.command('LOGOUT');
|
|
530
|
+
} finally {
|
|
531
|
+
session.destroy();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// -------------------------------------------------------------------------
|
|
536
|
+
// Private
|
|
537
|
+
// -------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
private session(): ImapSession {
|
|
540
|
+
const maxBodyBytes = this.options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
541
|
+
// SEC-3: cap literal size at the larger of 1 MB or 4× the configured body preview limit
|
|
542
|
+
const literalCap = Math.max(1_048_576, 4 * maxBodyBytes);
|
|
543
|
+
return new ImapSession(
|
|
544
|
+
this.options.socket,
|
|
545
|
+
this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
546
|
+
literalCap,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private async authenticate(session: ImapSession): Promise<void> {
|
|
551
|
+
const { username, password } = this.options;
|
|
552
|
+
if (password.startsWith('Bearer ')) {
|
|
553
|
+
// XOAUTH2 pass-through
|
|
554
|
+
const token = buildXOAuth2Token(username, password.slice(7));
|
|
555
|
+
await session.command(`AUTHENTICATE XOAUTH2 ${token}`);
|
|
556
|
+
} else {
|
|
557
|
+
// LOGIN — credentials are quoted per RFC 3501 to prevent injection (SEC-2).
|
|
558
|
+
// Credentials are not logged anywhere in this module.
|
|
559
|
+
const quotedUser = imapQuoteCredential(username, 'username');
|
|
560
|
+
const quotedPass = imapQuoteCredential(password, 'password');
|
|
561
|
+
await session.command(`LOGIN ${quotedUser} ${quotedPass}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
// TLS socket factory (production)
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Creates a TLS socket connected to an IMAP server on port 993 (or custom).
|
|
572
|
+
* Returns a connected Socket ready to pass to ImapClient.
|
|
573
|
+
*/
|
|
574
|
+
export function createImapTlsSocket(
|
|
575
|
+
host: string,
|
|
576
|
+
port: number,
|
|
577
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
578
|
+
): Promise<Socket> {
|
|
579
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
580
|
+
const { connect } = require('node:tls') as typeof import('node:tls');
|
|
581
|
+
const timer = setTimeout(() => {
|
|
582
|
+
sock.destroy();
|
|
583
|
+
reject(new Error(`IMAP TLS connect timeout to ${host}:${port}`));
|
|
584
|
+
}, timeoutMs);
|
|
585
|
+
|
|
586
|
+
const sock = connect({ host, port, servername: host }, () => {
|
|
587
|
+
clearTimeout(timer);
|
|
588
|
+
resolve(sock as unknown as Socket);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
sock.once('error', (err: Error) => {
|
|
592
|
+
clearTimeout(timer);
|
|
593
|
+
reject(err);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
}
|