@pellux/goodvibes-agent 1.4.4 → 1.5.1

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