@pellux/goodvibes-agent 1.4.3 → 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.
Files changed (227) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +7 -7
  3. package/dist/package/main.js +7450 -12285
  4. package/docs/README.md +2 -2
  5. package/docs/channels-remote-and-api.md +1 -1
  6. package/docs/getting-started.md +2 -2
  7. package/docs/release-and-publishing.md +1 -1
  8. package/docs/tools-and-commands.md +5 -5
  9. package/package.json +4 -2
  10. package/release/performance-snapshot.json +2 -2
  11. package/release/release-notes.md +11 -7
  12. package/release/release-readiness.json +6 -6
  13. package/src/agent/behavior-discovery-summary.ts +4 -18
  14. package/src/agent/calendar-registry.ts +322 -0
  15. package/src/agent/competitive-feature-inventory.ts +268 -130
  16. package/src/agent/document-registry.ts +5 -1
  17. package/src/agent/email/email-service.ts +350 -0
  18. package/src/agent/email/imap-client.ts +596 -0
  19. package/src/agent/email/smtp-client.ts +453 -0
  20. package/src/agent/ics-calendar.ts +662 -0
  21. package/src/agent/ics-timezone.ts +172 -0
  22. package/src/agent/markdown-frontmatter.ts +31 -0
  23. package/src/agent/memory-safety.ts +1 -1
  24. package/src/agent/note-registry.ts +3 -9
  25. package/src/agent/persona-discovery.ts +3 -15
  26. package/src/agent/persona-registry.ts +1 -10
  27. package/src/agent/research-source-registry.ts +5 -1
  28. package/src/agent/routine-discovery.ts +3 -15
  29. package/src/agent/runtime-profile.ts +3 -0
  30. package/src/agent/skill-discovery.ts +3 -15
  31. package/src/agent/skill-draft-proposer.ts +203 -0
  32. package/src/agent/skill-draft-runner.ts +201 -0
  33. package/src/agent/skill-registry.ts +36 -1
  34. package/src/agent/skill-standard.ts +99 -0
  35. package/src/agent/vibe-file.ts +7 -22
  36. package/src/cli/completion.ts +1 -1
  37. package/src/cli/config-overrides.ts +10 -1
  38. package/src/cli/redaction.ts +17 -5
  39. package/src/config/provider-model.ts +2 -1
  40. package/src/config/secret-config.ts +13 -6
  41. package/src/core/activity-feed.ts +97 -0
  42. package/src/core/away-digest.ts +161 -0
  43. package/src/core/conversation-rendering.ts +7 -3
  44. package/src/core/conversation.ts +22 -15
  45. package/src/core/hardware-profile.ts +362 -0
  46. package/src/core/last-seen-store.ts +78 -0
  47. package/src/core/plain-language.ts +52 -0
  48. package/src/core/setup-incomplete-hint.ts +90 -0
  49. package/src/core/system-message-router.ts +38 -87
  50. package/src/input/agent-workspace-basic-command-editor-submission.ts +60 -220
  51. package/src/input/agent-workspace-basic-command-editors.ts +39 -141
  52. package/src/input/agent-workspace-categories.ts +63 -158
  53. package/src/input/agent-workspace-command-editor.ts +4 -0
  54. package/src/input/agent-workspace-host-category.ts +0 -5
  55. package/src/input/agent-workspace-local-editor-submission.ts +3 -1
  56. package/src/input/agent-workspace-navigation.ts +10 -3
  57. package/src/input/agent-workspace-onboarding-actions.ts +132 -0
  58. package/src/input/agent-workspace-onboarding-categories.ts +29 -26
  59. package/src/input/agent-workspace-onboarding-finish.ts +11 -4
  60. package/src/input/agent-workspace-onboarding-state.ts +111 -0
  61. package/src/input/agent-workspace-profile-editor-submission.ts +260 -0
  62. package/src/input/agent-workspace-profile-editors.ts +155 -0
  63. package/src/input/agent-workspace-subscription-editor.ts +7 -0
  64. package/src/input/agent-workspace-types.ts +5 -1
  65. package/src/input/agent-workspace.ts +65 -39
  66. package/src/input/command-registry.ts +18 -5
  67. package/src/input/commands/agent-runtime-profile-runtime.ts +2 -1
  68. package/src/input/commands/agent-skills-runtime.ts +60 -3
  69. package/src/input/commands/calendar-runtime.ts +209 -0
  70. package/src/input/commands/channels-runtime.ts +1 -0
  71. package/src/input/commands/command-error.ts +9 -0
  72. package/src/input/commands/compat-runtime.ts +1 -0
  73. package/src/input/commands/config.ts +1 -0
  74. package/src/input/commands/conversation-runtime.ts +1 -1
  75. package/src/input/commands/delegation-runtime.ts +5 -6
  76. package/src/input/commands/email-runtime.ts +387 -0
  77. package/src/input/commands/experience-runtime.ts +17 -4
  78. package/src/input/commands/guidance-runtime.ts +1 -1
  79. package/src/input/commands/health-runtime.ts +6 -1
  80. package/src/input/commands/knowledge-format.ts +73 -0
  81. package/src/input/commands/knowledge.ts +9 -74
  82. package/src/input/commands/local-provider-runtime.ts +2 -1
  83. package/src/input/commands/local-runtime.ts +10 -4
  84. package/src/input/commands/mcp-runtime.ts +7 -0
  85. package/src/input/commands/notify-runtime.ts +17 -1
  86. package/src/input/commands/onboarding-runtime.ts +1 -0
  87. package/src/input/commands/operator-actions-runtime.ts +1 -0
  88. package/src/input/commands/operator-runtime.ts +3 -0
  89. package/src/input/commands/personas-runtime.ts +1 -0
  90. package/src/input/commands/platform-access-runtime.ts +40 -17
  91. package/src/input/commands/product-runtime.ts +6 -1
  92. package/src/input/commands/provider-accounts-runtime.ts +3 -2
  93. package/src/input/commands/qrcode-runtime.ts +1 -0
  94. package/src/input/commands/routines-runtime.ts +1 -0
  95. package/src/input/commands/runtime-services.ts +0 -13
  96. package/src/input/commands/security-runtime.ts +1 -0
  97. package/src/input/commands/session-content.ts +1 -0
  98. package/src/input/commands/shell-core.ts +5 -2
  99. package/src/input/commands/subscription-runtime.ts +33 -9
  100. package/src/input/commands/support-bundle-runtime.ts +8 -1
  101. package/src/input/commands/tasks-runtime.ts +1 -0
  102. package/src/input/commands/tts-runtime.ts +1 -0
  103. package/src/input/commands/vibe-runtime.ts +1 -0
  104. package/src/input/commands.ts +4 -0
  105. package/src/input/feed-context-factory.ts +3 -12
  106. package/src/input/handler-command-route.ts +7 -60
  107. package/src/input/handler-feed-routes.ts +0 -194
  108. package/src/input/handler-feed.ts +2 -54
  109. package/src/input/handler-interactions.ts +0 -2
  110. package/src/input/handler-modal-stack.ts +0 -9
  111. package/src/input/handler-picker-routes.ts +24 -1
  112. package/src/input/handler-shortcuts.ts +11 -40
  113. package/src/input/handler-ui-state.ts +13 -0
  114. package/src/input/handler.ts +8 -35
  115. package/src/input/keybindings.ts +40 -17
  116. package/src/input/model-picker-local-fit.ts +130 -0
  117. package/src/input/submission-router.ts +0 -5
  118. package/src/main.ts +111 -89
  119. package/src/renderer/activity-sidebar.ts +186 -0
  120. package/src/renderer/agent-workspace-context-lines.ts +5 -48
  121. package/src/renderer/agent-workspace-style.ts +1 -1
  122. package/src/renderer/agent-workspace.ts +14 -2
  123. package/src/renderer/compositor.ts +37 -125
  124. package/src/renderer/conversation-overlays.ts +3 -3
  125. package/src/renderer/help-overlay.ts +7 -5
  126. package/src/renderer/model-workspace.ts +101 -86
  127. package/src/{panels → renderer}/polish.ts +3 -3
  128. package/src/renderer/shell-surface.ts +4 -5
  129. package/src/renderer/status-token.ts +31 -11
  130. package/src/renderer/tab-strip.ts +1 -1
  131. package/src/renderer/tool-call.ts +7 -8
  132. package/src/renderer/tool-labels.ts +92 -0
  133. package/src/renderer/ui-factory.ts +48 -96
  134. package/src/runtime/bootstrap-command-context.ts +0 -5
  135. package/src/runtime/bootstrap-command-parts.ts +6 -15
  136. package/src/runtime/bootstrap-core.ts +34 -13
  137. package/src/runtime/bootstrap-hook-bridge.ts +3 -1
  138. package/src/runtime/bootstrap-shell.ts +3 -50
  139. package/src/runtime/bootstrap.ts +3 -4
  140. package/src/runtime/context.ts +1 -1
  141. package/src/runtime/diagnostics/panels/index.ts +0 -1
  142. package/src/runtime/diagnostics/panels/policy.ts +1 -1
  143. package/src/runtime/execution-ledger.ts +16 -1
  144. package/src/runtime/index.ts +6 -1
  145. package/src/runtime/onboarding/index.ts +1 -0
  146. package/src/runtime/onboarding/onboarding-state.ts +200 -0
  147. package/src/{panels → runtime}/provider-account-snapshot.ts +5 -2
  148. package/src/runtime/services.ts +16 -4
  149. package/src/runtime/terminal-output-guard.ts +7 -0
  150. package/src/runtime/tool-permission-safety.ts +1 -1
  151. package/src/runtime/ui/model-picker/data-provider.ts +1 -1
  152. package/src/runtime/ui/model-picker/health-enrichment.ts +2 -2
  153. package/src/runtime/ui/model-picker/types.ts +2 -2
  154. package/src/runtime/ui/provider-health/data-provider.ts +3 -3
  155. package/src/runtime/ui/provider-health/types.ts +2 -2
  156. package/src/runtime/ui-services.ts +0 -2
  157. package/src/shell/agent-workspace-fullscreen.ts +0 -1
  158. package/src/shell/autonomy-surfacing.ts +272 -0
  159. package/src/shell/session-continuity-hints.ts +0 -6
  160. package/src/shell/startup-wiring.ts +221 -0
  161. package/src/shell/ui-openers.ts +18 -29
  162. package/src/tools/agent-context-policy.ts +1 -1
  163. package/src/tools/agent-harness-background-processes.ts +4 -4
  164. package/src/tools/agent-harness-browser-cockpit-route.ts +3 -3
  165. package/src/tools/agent-harness-command-runner.ts +6 -1
  166. package/src/tools/agent-harness-document-ops.ts +26 -53
  167. package/src/tools/agent-harness-execution-history.ts +1 -13
  168. package/src/tools/agent-harness-execution-posture.ts +0 -15
  169. package/src/tools/agent-harness-keybinding-metadata.ts +28 -29
  170. package/src/tools/agent-harness-local-model-cookbook.ts +24 -1
  171. package/src/tools/agent-harness-metadata.ts +16 -0
  172. package/src/tools/agent-harness-mode-catalog.ts +2 -9
  173. package/src/tools/agent-harness-provider-account-metadata.ts +2 -2
  174. package/src/tools/agent-harness-setup-posture-utils.ts +1 -1
  175. package/src/tools/agent-harness-tool-schema.ts +13 -14
  176. package/src/tools/agent-harness-tool.ts +27 -23
  177. package/src/tools/agent-harness-ui-surface-metadata.ts +10 -20
  178. package/src/tools/agent-harness-workspace-action-runner.ts +3 -4
  179. package/src/tools/agent-workspace-tool.ts +2 -33
  180. package/src/utils/terminal-width.ts +9 -3
  181. package/src/version.ts +1 -1
  182. package/src/input/agent-workspace-panel-route.ts +0 -44
  183. package/src/input/panel-integration-actions.ts +0 -26
  184. package/src/panels/approval-panel.ts +0 -149
  185. package/src/panels/automation-control-panel.ts +0 -212
  186. package/src/panels/base-panel.ts +0 -254
  187. package/src/panels/builtin/agent.ts +0 -58
  188. package/src/panels/builtin/knowledge.ts +0 -26
  189. package/src/panels/builtin/operations.ts +0 -121
  190. package/src/panels/builtin/session.ts +0 -138
  191. package/src/panels/builtin/shared.ts +0 -275
  192. package/src/panels/builtin/usage.ts +0 -21
  193. package/src/panels/builtin-panels.ts +0 -23
  194. package/src/panels/confirm-state.ts +0 -61
  195. package/src/panels/context-visualizer-panel.ts +0 -204
  196. package/src/panels/cost-tracker-panel.ts +0 -444
  197. package/src/panels/docs-panel.ts +0 -285
  198. package/src/panels/index.ts +0 -25
  199. package/src/panels/knowledge-panel.ts +0 -417
  200. package/src/panels/memory-panel.ts +0 -226
  201. package/src/panels/panel-list-panel.ts +0 -464
  202. package/src/panels/panel-manager.ts +0 -570
  203. package/src/panels/provider-accounts-panel.ts +0 -233
  204. package/src/panels/provider-health-domains.ts +0 -208
  205. package/src/panels/provider-health-panel.ts +0 -720
  206. package/src/panels/provider-health-tracker.ts +0 -115
  207. package/src/panels/provider-stats-panel.ts +0 -366
  208. package/src/panels/qr-panel.ts +0 -207
  209. package/src/panels/schedule-panel.ts +0 -321
  210. package/src/panels/scrollable-list-panel.ts +0 -491
  211. package/src/panels/search-focus.ts +0 -32
  212. package/src/panels/security-panel.ts +0 -295
  213. package/src/panels/session-browser-panel.ts +0 -395
  214. package/src/panels/session-maintenance.ts +0 -125
  215. package/src/panels/subscription-panel.ts +0 -263
  216. package/src/panels/system-messages-panel.ts +0 -230
  217. package/src/panels/tasks-panel.ts +0 -344
  218. package/src/panels/thinking-panel.ts +0 -304
  219. package/src/panels/token-budget-panel.ts +0 -475
  220. package/src/panels/tool-inspector-panel.ts +0 -436
  221. package/src/panels/types.ts +0 -54
  222. package/src/renderer/panel-composite.ts +0 -158
  223. package/src/renderer/panel-tab-bar.ts +0 -69
  224. package/src/renderer/panel-workspace-bar.ts +0 -42
  225. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  226. package/src/tools/agent-harness-panel-metadata.ts +0 -211
  227. /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
+ }