@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.
- package/CHANGELOG.md +22 -0
- package/README.md +52 -7
- package/dist/package/main.js +7491 -12253
- 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/live-verification/live-verification.json +13 -13
- package/release/live-verification/live-verification.md +15 -15
- package/release/performance-snapshot.json +2 -2
- package/release/release-notes.md +5 -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/shell-passthrough.ts +58 -0
- package/src/input/submission-router.ts +0 -5
- package/src/main.ts +129 -90
- 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,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SMTP submission client over an injectable transport socket.
|
|
3
|
+
*
|
|
4
|
+
* Scope and honest boundaries
|
|
5
|
+
* ────────────────────────────
|
|
6
|
+
* Supported:
|
|
7
|
+
* - EHLO negotiation
|
|
8
|
+
* - AUTH PLAIN and AUTH LOGIN
|
|
9
|
+
* - MAIL FROM / RCPT TO / DATA with RFC 2821 dot-stuffing
|
|
10
|
+
* - QUIT
|
|
11
|
+
* - TLS-direct (port 465) via `createSmtpTlsSocket()`
|
|
12
|
+
* - STARTTLS upgrade (port 587) via `createSmtpStartTlsSocket()`
|
|
13
|
+
*
|
|
14
|
+
* Not supported (document boundaries):
|
|
15
|
+
* - HTML, MIME multipart, attachments
|
|
16
|
+
* - Multiple recipients in a single session (call sendMail per recipient)
|
|
17
|
+
* - DSN / delivery-status-notification extensions
|
|
18
|
+
* - PIPELINING (all commands are sent sequentially and await a reply)
|
|
19
|
+
* - Credentials are never logged
|
|
20
|
+
*
|
|
21
|
+
* Transport injection
|
|
22
|
+
* ────────────────────
|
|
23
|
+
* The `socket` parameter accepts any net.Socket so that unit tests can supply
|
|
24
|
+
* a plain in-process fake socket instead of a real TLS connection.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { Socket } from 'node:net';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Public types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface SmtpClientOptions {
|
|
34
|
+
readonly socket: Socket;
|
|
35
|
+
readonly hostname: string;
|
|
36
|
+
readonly username: string;
|
|
37
|
+
readonly password: string;
|
|
38
|
+
readonly timeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SmtpSendOptions {
|
|
42
|
+
readonly from: string;
|
|
43
|
+
readonly to: string;
|
|
44
|
+
readonly subject: string;
|
|
45
|
+
readonly body: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Internal helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
53
|
+
const CRLF = '\r\n';
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Input validation (SEC-1: SMTP header/command injection prevention)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/** Control-character pattern: CR, LF, and other C0/C1 control characters. */
|
|
60
|
+
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f-\x9f]/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate a single SMTP envelope address (MAIL FROM / RCPT TO value).
|
|
64
|
+
* Rejects: control characters (\r, \n, any C0/C1), spaces, angle brackets,
|
|
65
|
+
* comma-separated lists. Only a single bare address is accepted.
|
|
66
|
+
*
|
|
67
|
+
* @throws Error with a plain-language message on invalid input.
|
|
68
|
+
*/
|
|
69
|
+
export function validateSmtpAddress(address: string, field: string): void {
|
|
70
|
+
if (CONTROL_CHAR_RE.test(address)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Invalid ${field}: address must not contain control characters (CR, LF, etc.).`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (address.includes(' ') || address.includes('\t')) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Invalid ${field}: address must be a single bare address with no spaces or tabs.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (address.includes('<') || address.includes('>')) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Invalid ${field}: address must be a single bare address without angle brackets.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (address.includes(',')) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Invalid ${field}: only one address is allowed per field (no comma-separated lists).`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (!address.includes('@')) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Invalid ${field}: "${address}" does not look like an email address (missing @).`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate an SMTP message Subject header value.
|
|
99
|
+
* Rejects control characters (\r, \n, etc.) that could split headers.
|
|
100
|
+
*
|
|
101
|
+
* @throws Error with a plain-language message on invalid input.
|
|
102
|
+
*/
|
|
103
|
+
export function validateSmtpSubject(subject: string): void {
|
|
104
|
+
if (CONTROL_CHAR_RE.test(subject)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'Invalid subject: subject must not contain control characters (CR, LF, etc.).',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function dotStuff(body: string): string {
|
|
112
|
+
// RFC 2821 §4.5.2: lines beginning with '.' get an extra '.'
|
|
113
|
+
return body
|
|
114
|
+
.replace(/\r\n/g, '\n')
|
|
115
|
+
.split('\n')
|
|
116
|
+
.map((line) => (line.startsWith('.') ? `.${line}` : line))
|
|
117
|
+
.join(CRLF);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function base64(str: string): string {
|
|
121
|
+
return Buffer.from(str, 'utf8').toString('base64');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// SMTP session
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
class SmtpSession {
|
|
129
|
+
private readonly socket: Socket;
|
|
130
|
+
private readonly timeoutMs: number;
|
|
131
|
+
private buffer = '';
|
|
132
|
+
|
|
133
|
+
constructor(socket: Socket, timeoutMs: number) {
|
|
134
|
+
this.socket = socket;
|
|
135
|
+
this.timeoutMs = timeoutMs;
|
|
136
|
+
this.socket.setEncoding('utf8');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Read lines until a final SMTP response (no continuation dash). */
|
|
140
|
+
async readResponse(): Promise<{ code: number; lines: string[] }> {
|
|
141
|
+
const lines: string[] = [];
|
|
142
|
+
|
|
143
|
+
return new Promise<{ code: number; lines: string[] }>((resolve, reject) => {
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
cleanup();
|
|
146
|
+
reject(new Error('SMTP read timeout'));
|
|
147
|
+
}, this.timeoutMs);
|
|
148
|
+
|
|
149
|
+
const tryFlush = (): void => {
|
|
150
|
+
let pos: number;
|
|
151
|
+
while ((pos = this.buffer.indexOf('\n')) !== -1) {
|
|
152
|
+
const line = this.buffer.slice(0, pos).replace(/\r$/, '');
|
|
153
|
+
this.buffer = this.buffer.slice(pos + 1);
|
|
154
|
+
lines.push(line);
|
|
155
|
+
|
|
156
|
+
// SMTP responses: XYZ-text (continuation) vs XYZ text (final)
|
|
157
|
+
const match = /^(\d{3})([- ])/.exec(line);
|
|
158
|
+
if (match && match[2] !== '-') {
|
|
159
|
+
cleanup();
|
|
160
|
+
const code = parseInt(match[1] ?? '0', 10);
|
|
161
|
+
resolve({ code, lines });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const onData = (chunk: string): void => {
|
|
168
|
+
this.buffer += chunk;
|
|
169
|
+
tryFlush();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const onError = (err: Error): void => {
|
|
173
|
+
cleanup();
|
|
174
|
+
reject(err);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const onClose = (): void => {
|
|
178
|
+
cleanup();
|
|
179
|
+
reject(new Error('SMTP connection closed unexpectedly'));
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const cleanup = (): void => {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
this.socket.off('data', onData);
|
|
185
|
+
this.socket.off('error', onError);
|
|
186
|
+
this.socket.off('close', onClose);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
this.socket.on('data', onData);
|
|
190
|
+
this.socket.on('error', onError);
|
|
191
|
+
this.socket.on('close', onClose);
|
|
192
|
+
|
|
193
|
+
// Flush already-buffered data
|
|
194
|
+
if (this.buffer.length > 0) {
|
|
195
|
+
tryFlush();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async send(line: string): Promise<void> {
|
|
201
|
+
return new Promise<void>((resolve, reject) => {
|
|
202
|
+
this.socket.write(`${line}${CRLF}`, 'utf8', (err) => {
|
|
203
|
+
if (err) reject(err);
|
|
204
|
+
else resolve();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async sendRaw(data: string): Promise<void> {
|
|
210
|
+
return new Promise<void>((resolve, reject) => {
|
|
211
|
+
this.socket.write(data, 'utf8', (err) => {
|
|
212
|
+
if (err) reject(err);
|
|
213
|
+
else resolve();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Send a command and assert the response code. Throws on failure. */
|
|
219
|
+
async cmd(line: string, expectCode: number): Promise<{ code: number; lines: string[] }> {
|
|
220
|
+
await this.send(line);
|
|
221
|
+
const resp = await this.readResponse();
|
|
222
|
+
if (resp.code !== expectCode) {
|
|
223
|
+
throw new Error(`SMTP ${line.split(' ')[0] ?? 'CMD'} failed: ${resp.lines.join(' | ')}`);
|
|
224
|
+
}
|
|
225
|
+
return resp;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
destroy(): void {
|
|
229
|
+
try {
|
|
230
|
+
this.socket.destroy();
|
|
231
|
+
} catch {
|
|
232
|
+
// ignore
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Public client
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
export class SmtpClient {
|
|
242
|
+
private readonly options: SmtpClientOptions;
|
|
243
|
+
|
|
244
|
+
constructor(options: SmtpClientOptions) {
|
|
245
|
+
this.options = options;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Send a plain-text email.
|
|
250
|
+
* Callers must ensure the caller-side confirms the send before calling this.
|
|
251
|
+
*/
|
|
252
|
+
async sendMail(opts: SmtpSendOptions): Promise<void> {
|
|
253
|
+
const { hostname, username, password } = this.options;
|
|
254
|
+
const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
255
|
+
const session = new SmtpSession(this.options.socket, timeoutMs);
|
|
256
|
+
|
|
257
|
+
// Read server greeting
|
|
258
|
+
const greeting = await session.readResponse();
|
|
259
|
+
if (greeting.code !== 220) {
|
|
260
|
+
throw new Error(`SMTP unexpected greeting: ${greeting.lines.join(' | ')}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// EHLO
|
|
264
|
+
const ehlo = await session.cmd(`EHLO ${hostname}`, 250);
|
|
265
|
+
const capabilities = ehlo.lines.map((l) => l.slice(4).trim().toUpperCase());
|
|
266
|
+
|
|
267
|
+
// AUTH
|
|
268
|
+
await this.authenticate(session, capabilities, username, password);
|
|
269
|
+
|
|
270
|
+
// Validate envelope fields before writing to the protocol stream (SEC-1)
|
|
271
|
+
validateSmtpAddress(opts.from, 'from');
|
|
272
|
+
validateSmtpAddress(opts.to, 'to');
|
|
273
|
+
validateSmtpSubject(opts.subject);
|
|
274
|
+
|
|
275
|
+
// Envelope
|
|
276
|
+
await session.cmd(`MAIL FROM:<${opts.from}>`, 250);
|
|
277
|
+
await session.cmd(`RCPT TO:<${opts.to}>`, 250);
|
|
278
|
+
|
|
279
|
+
// DATA
|
|
280
|
+
await session.cmd('DATA', 354);
|
|
281
|
+
|
|
282
|
+
const date = new Date().toUTCString();
|
|
283
|
+
const message = [
|
|
284
|
+
`From: ${opts.from}`,
|
|
285
|
+
`To: ${opts.to}`,
|
|
286
|
+
`Subject: ${opts.subject}`,
|
|
287
|
+
`Date: ${date}`,
|
|
288
|
+
`MIME-Version: 1.0`,
|
|
289
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
290
|
+
'',
|
|
291
|
+
dotStuff(opts.body),
|
|
292
|
+
'.',
|
|
293
|
+
].join(CRLF) + CRLF;
|
|
294
|
+
|
|
295
|
+
await session.sendRaw(message);
|
|
296
|
+
const dataResp = await session.readResponse();
|
|
297
|
+
if (dataResp.code !== 250) {
|
|
298
|
+
throw new Error(`SMTP DATA rejected: ${dataResp.lines.join(' | ')}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// QUIT
|
|
302
|
+
await session.send('QUIT');
|
|
303
|
+
session.destroy();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// -------------------------------------------------------------------------
|
|
307
|
+
// Private
|
|
308
|
+
// -------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
private async authenticate(
|
|
311
|
+
session: SmtpSession,
|
|
312
|
+
capabilities: readonly string[],
|
|
313
|
+
username: string,
|
|
314
|
+
password: string,
|
|
315
|
+
): Promise<void> {
|
|
316
|
+
const hasPlain = capabilities.some((c) => c.includes('AUTH') && c.includes('PLAIN'));
|
|
317
|
+
const hasLogin = capabilities.some((c) => c.includes('AUTH') && c.includes('LOGIN'));
|
|
318
|
+
|
|
319
|
+
if (hasPlain) {
|
|
320
|
+
// AUTH PLAIN: base64(\x00username\x00password)
|
|
321
|
+
const token = base64(`\x00${username}\x00${password}`);
|
|
322
|
+
await session.cmd(`AUTH PLAIN ${token}`, 235);
|
|
323
|
+
} else if (hasLogin) {
|
|
324
|
+
// AUTH LOGIN: two-step base64 exchange
|
|
325
|
+
await session.cmd('AUTH LOGIN', 334);
|
|
326
|
+
await session.cmd(base64(username), 334);
|
|
327
|
+
await session.cmd(base64(password), 235);
|
|
328
|
+
} else {
|
|
329
|
+
throw new Error('SMTP server does not advertise AUTH PLAIN or AUTH LOGIN');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Socket factories (production)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/** Connect TLS-direct (implicit TLS, port 465). */
|
|
339
|
+
export function createSmtpTlsSocket(
|
|
340
|
+
host: string,
|
|
341
|
+
port: number,
|
|
342
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
343
|
+
): Promise<Socket> {
|
|
344
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
345
|
+
const { connect } = require('node:tls') as typeof import('node:tls');
|
|
346
|
+
const timer = setTimeout(() => {
|
|
347
|
+
sock.destroy();
|
|
348
|
+
reject(new Error(`SMTP TLS connect timeout to ${host}:${port}`));
|
|
349
|
+
}, timeoutMs);
|
|
350
|
+
|
|
351
|
+
const sock = connect({ host, port, servername: host }, () => {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
resolve(sock as unknown as Socket);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
sock.once('error', (err: Error) => {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
reject(err);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Connect plain then upgrade via STARTTLS (port 587). */
|
|
364
|
+
export function createSmtpStartTlsSocket(
|
|
365
|
+
host: string,
|
|
366
|
+
port: number,
|
|
367
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
368
|
+
): Promise<Socket> {
|
|
369
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
370
|
+
const net = require('node:net') as typeof import('node:net');
|
|
371
|
+
const tls = require('node:tls') as typeof import('node:tls');
|
|
372
|
+
|
|
373
|
+
const timer = setTimeout(() => {
|
|
374
|
+
plain.destroy();
|
|
375
|
+
reject(new Error(`SMTP STARTTLS connect timeout to ${host}:${port}`));
|
|
376
|
+
}, timeoutMs);
|
|
377
|
+
|
|
378
|
+
const plain = net.connect({ host, port }, () => {
|
|
379
|
+
// Read the 220 greeting, send EHLO, then STARTTLS
|
|
380
|
+
let buffer = '';
|
|
381
|
+
plain.setEncoding('utf8');
|
|
382
|
+
|
|
383
|
+
const onGreeting = (chunk: string): void => {
|
|
384
|
+
buffer += chunk;
|
|
385
|
+
if (!buffer.includes('\n')) return;
|
|
386
|
+
plain.off('data', onGreeting);
|
|
387
|
+
|
|
388
|
+
plain.write(`EHLO ${host}${CRLF}`, 'utf8', () => {
|
|
389
|
+
let ehloBuffer = '';
|
|
390
|
+
const onEhlo = (data: string): void => {
|
|
391
|
+
ehloBuffer += data;
|
|
392
|
+
// Wait for final 250 line (no dash)
|
|
393
|
+
if (!/^250 /m.test(ehloBuffer)) return;
|
|
394
|
+
plain.off('data', onEhlo);
|
|
395
|
+
|
|
396
|
+
plain.write(`STARTTLS${CRLF}`, 'utf8', () => {
|
|
397
|
+
let stBuffer = '';
|
|
398
|
+
const onStartTls = (data: string): void => {
|
|
399
|
+
stBuffer += data;
|
|
400
|
+
if (!stBuffer.includes('\n')) return;
|
|
401
|
+
plain.off('data', onStartTls);
|
|
402
|
+
|
|
403
|
+
if (!stBuffer.startsWith('220')) {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
plain.destroy();
|
|
406
|
+
reject(new Error(`STARTTLS rejected: ${stBuffer.trim()}`));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// SEC-4: assert no pipelined data arrived after the 220 STARTTLS reply.
|
|
411
|
+
// Any bytes beyond the \n of the 220 line indicate a hostile server
|
|
412
|
+
// injecting data before TLS negotiation begins.
|
|
413
|
+
const newlineIdx = stBuffer.indexOf('\n');
|
|
414
|
+
const afterNewline = newlineIdx !== -1 ? stBuffer.slice(newlineIdx + 1) : '';
|
|
415
|
+
if (afterNewline.length > 0) {
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
plain.destroy();
|
|
418
|
+
reject(new Error(
|
|
419
|
+
'STARTTLS aborted: server sent data after the 220 response before TLS upgrade. ' +
|
|
420
|
+
'This may indicate a STARTTLS injection attack.',
|
|
421
|
+
));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const upgraded = tls.connect({
|
|
426
|
+
socket: plain,
|
|
427
|
+
host,
|
|
428
|
+
servername: host,
|
|
429
|
+
}, () => {
|
|
430
|
+
clearTimeout(timer);
|
|
431
|
+
resolve(upgraded as unknown as Socket);
|
|
432
|
+
});
|
|
433
|
+
upgraded.once('error', (err: Error) => {
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
reject(err);
|
|
436
|
+
});
|
|
437
|
+
};
|
|
438
|
+
plain.on('data', onStartTls);
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
plain.on('data', onEhlo);
|
|
442
|
+
});
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
plain.on('data', onGreeting);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
plain.once('error', (err) => {
|
|
449
|
+
clearTimeout(timer);
|
|
450
|
+
reject(err);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
}
|