@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
|
@@ -664,7 +664,11 @@ export class AgentDocumentRegistry {
|
|
|
664
664
|
|
|
665
665
|
private readStore(): DocumentStoreFile {
|
|
666
666
|
if (!existsSync(this.storePath)) return { version: STORE_VERSION, documents: [] };
|
|
667
|
-
|
|
667
|
+
try {
|
|
668
|
+
return parseStore(readFileSync(this.storePath, 'utf-8'));
|
|
669
|
+
} catch (error) {
|
|
670
|
+
throw new Error(`Could not read Agent document store: ${error instanceof Error ? error.message : String(error)}`);
|
|
671
|
+
}
|
|
668
672
|
}
|
|
669
673
|
|
|
670
674
|
private writeStore(store: DocumentStoreFile): void {
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email service — config, secret resolution, and orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Config namespace: email.*
|
|
5
|
+
* ─────────────────────────
|
|
6
|
+
* The SDK's ConfigKey union is sealed and does not include email keys.
|
|
7
|
+
* Email settings are accessed via ensureEmailConfigDefaults(), which
|
|
8
|
+
* injects the email section into the ConfigManager's live config object
|
|
9
|
+
* before first use. This follows the same pattern as other app-layer
|
|
10
|
+
* categories that extend beyond the SDK's built-in schema.
|
|
11
|
+
*
|
|
12
|
+
* Settings registered:
|
|
13
|
+
* email.enabled boolean — feature gate (default: false)
|
|
14
|
+
* email.imapHost string — IMAP TLS host (default: '')
|
|
15
|
+
* email.imapPort number — default 993
|
|
16
|
+
* email.smtpHost string — SMTP submission host (default: '')
|
|
17
|
+
* email.smtpPort number — 465 (TLS) or 587 (STARTTLS, default)
|
|
18
|
+
* email.username string — login username (default: '')
|
|
19
|
+
* email.passwordRef string — goodvibes:// secret reference only;
|
|
20
|
+
* NEVER a raw password
|
|
21
|
+
* email.fromAddress string — From: address for outbound mail
|
|
22
|
+
*
|
|
23
|
+
* Secret resolution
|
|
24
|
+
* ─────────────────
|
|
25
|
+
* `email.passwordRef` must be a goodvibes secret reference string.
|
|
26
|
+
* The service calls `secretsManager.get(resolvedKey)` using the same
|
|
27
|
+
* SecretsManager instance already wired in CommandContext.platform.
|
|
28
|
+
* Plaintext passwords in config are rejected at validation time.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { ImapClient, createImapTlsSocket } from './imap-client.ts';
|
|
32
|
+
import { SmtpClient, createSmtpTlsSocket, createSmtpStartTlsSocket, validateSmtpAddress, validateSmtpSubject } from './smtp-client.ts';
|
|
33
|
+
import type { ImapEnvelope } from './imap-client.ts';
|
|
34
|
+
import type { Socket } from 'node:net';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Email defaults injection
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Email section defaults — injected once per ConfigManager instance. */
|
|
41
|
+
const EMAIL_DEFAULTS = {
|
|
42
|
+
enabled: false,
|
|
43
|
+
imapHost: '',
|
|
44
|
+
imapPort: 993,
|
|
45
|
+
smtpHost: '',
|
|
46
|
+
smtpPort: 587,
|
|
47
|
+
smtpSecurity: 'auto' as const,
|
|
48
|
+
username: '',
|
|
49
|
+
passwordRef: '',
|
|
50
|
+
fromAddress: '',
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Inject the email config section into the ConfigManager's live config
|
|
55
|
+
* object if it is not already present.
|
|
56
|
+
*
|
|
57
|
+
* The SDK's DEFAULT_CONFIG does not include an email section because email
|
|
58
|
+
* is an app-layer feature. ConfigManager.resolvePath() walks the live config
|
|
59
|
+
* object and throws for any section that does not exist. Calling this helper
|
|
60
|
+
* once before any email.* access ensures the traversal succeeds.
|
|
61
|
+
*
|
|
62
|
+
* The helper is safe to call multiple times — it is a no-op after the first
|
|
63
|
+
* call for a given configManager instance.
|
|
64
|
+
*/
|
|
65
|
+
export function ensureEmailConfigDefaults(
|
|
66
|
+
configManager: object,
|
|
67
|
+
): void {
|
|
68
|
+
// Use the internal config object via an opaque cast. This is the sanctioned
|
|
69
|
+
// app-layer extension pattern for categories absent from the SDK schema.
|
|
70
|
+
const cm = configManager as unknown as { config: Record<string, unknown> };
|
|
71
|
+
if (cm.config && !('email' in cm.config)) {
|
|
72
|
+
cm.config['email'] = { ...EMAIL_DEFAULTS };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Public types
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/** SMTP connection security mode. 'auto' = port-based default (465→tls, else starttls). */
|
|
81
|
+
export type SmtpSecurityMode = 'tls' | 'starttls' | 'auto';
|
|
82
|
+
|
|
83
|
+
export interface EmailConfig {
|
|
84
|
+
readonly enabled: boolean;
|
|
85
|
+
readonly imapHost: string;
|
|
86
|
+
readonly imapPort: number;
|
|
87
|
+
readonly smtpHost: string;
|
|
88
|
+
readonly smtpPort: number;
|
|
89
|
+
/** SMTP connection security. Default: 'auto' (port-based). */
|
|
90
|
+
readonly smtpSecurity: SmtpSecurityMode;
|
|
91
|
+
readonly username: string;
|
|
92
|
+
/** Secret reference string — never a raw password. */
|
|
93
|
+
readonly passwordRef: string;
|
|
94
|
+
readonly fromAddress: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface EmailSummary {
|
|
98
|
+
readonly from: string;
|
|
99
|
+
readonly subject: string;
|
|
100
|
+
readonly date: string;
|
|
101
|
+
readonly unread: boolean;
|
|
102
|
+
/** First ~4 KB of the plain-text body, fetched read-only. Empty string when unavailable. */
|
|
103
|
+
readonly bodyPreview: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface SendMailOptions {
|
|
107
|
+
readonly to: string;
|
|
108
|
+
readonly subject: string;
|
|
109
|
+
readonly body: string;
|
|
110
|
+
/** Must be true at the call site; the service rejects sends without it. */
|
|
111
|
+
readonly confirm: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface EmailServiceDeps {
|
|
115
|
+
/** Untyped config getter — mirrors the pattern used in channels-command tests. */
|
|
116
|
+
readonly getConfig: (key: string) => unknown;
|
|
117
|
+
/** SecretsManager-compatible interface for resolving secret refs. */
|
|
118
|
+
readonly secretsManager: {
|
|
119
|
+
readonly get: (key: string) => Promise<string | null>;
|
|
120
|
+
};
|
|
121
|
+
/** Optional socket factory override for IMAP (injected in tests). */
|
|
122
|
+
readonly imapSocketFactory?: (host: string, port: number) => Promise<Socket>;
|
|
123
|
+
/** Optional socket factory override for SMTP (injected in tests). */
|
|
124
|
+
readonly smtpSocketFactory?: (host: string, port: number) => Promise<Socket>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Config reading
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function readString(value: unknown, fallback = ''): string {
|
|
132
|
+
return typeof value === 'string' ? value : fallback;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readNumber(value: unknown, fallback: number): number {
|
|
136
|
+
const n = typeof value === 'number' ? value : parseInt(String(value), 10);
|
|
137
|
+
return isFinite(n) ? n : fallback;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readBoolean(value: unknown, fallback = false): boolean {
|
|
141
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readSmtpSecurity(value: unknown): SmtpSecurityMode {
|
|
145
|
+
if (value === 'tls' || value === 'starttls' || value === 'auto') return value;
|
|
146
|
+
return 'auto';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function readEmailConfig(getConfig: (key: string) => unknown): EmailConfig {
|
|
150
|
+
return {
|
|
151
|
+
enabled: readBoolean(getConfig('email.enabled'), false),
|
|
152
|
+
imapHost: readString(getConfig('email.imapHost')),
|
|
153
|
+
imapPort: readNumber(getConfig('email.imapPort'), 993),
|
|
154
|
+
smtpHost: readString(getConfig('email.smtpHost')),
|
|
155
|
+
smtpPort: readNumber(getConfig('email.smtpPort'), 587),
|
|
156
|
+
smtpSecurity: readSmtpSecurity(getConfig('email.smtpSecurity')),
|
|
157
|
+
username: readString(getConfig('email.username')),
|
|
158
|
+
passwordRef: readString(getConfig('email.passwordRef')),
|
|
159
|
+
fromAddress: readString(getConfig('email.fromAddress')),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function validateEmailConfig(config: EmailConfig): string[] {
|
|
164
|
+
const errors: string[] = [];
|
|
165
|
+
if (!config.imapHost) errors.push('email.imapHost is required');
|
|
166
|
+
if (!config.smtpHost) errors.push('email.smtpHost is required');
|
|
167
|
+
if (!config.username) errors.push('email.username is required');
|
|
168
|
+
if (!config.passwordRef) {
|
|
169
|
+
errors.push('email.passwordRef is required (must be a secret reference, not a raw password)');
|
|
170
|
+
} else if (!config.passwordRef.startsWith('goodvibes://secrets/')) {
|
|
171
|
+
errors.push('email.passwordRef must be a goodvibes secret reference (goodvibes://secrets/...)');
|
|
172
|
+
}
|
|
173
|
+
if (!config.fromAddress) errors.push('email.fromAddress is required');
|
|
174
|
+
return errors;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Secret resolution
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Extract the storage key from a goodvibes://secrets/goodvibes/<key> ref.
|
|
183
|
+
* For other secret ref types (env, file, bitwarden, etc.) we cannot resolve
|
|
184
|
+
* them directly — the user should configure via the standard secret manager
|
|
185
|
+
* path. We return the raw ref string for those cases so the SecretsManager
|
|
186
|
+
* can attempt its own resolution chain.
|
|
187
|
+
*/
|
|
188
|
+
function extractSecretKey(passwordRef: string): string {
|
|
189
|
+
const prefix = 'goodvibes://secrets/goodvibes/';
|
|
190
|
+
if (passwordRef.startsWith(prefix)) {
|
|
191
|
+
return decodeURIComponent(passwordRef.slice(prefix.length));
|
|
192
|
+
}
|
|
193
|
+
// Return the full ref for the SDK SecretsManager to resolve
|
|
194
|
+
return passwordRef;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function resolveEmailPassword(
|
|
198
|
+
passwordRef: string,
|
|
199
|
+
secretsManager: { readonly get: (key: string) => Promise<string | null> },
|
|
200
|
+
): Promise<string> {
|
|
201
|
+
const key = extractSecretKey(passwordRef);
|
|
202
|
+
const value = await secretsManager.get(key);
|
|
203
|
+
if (!value) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
'Email password secret could not be resolved. ' +
|
|
206
|
+
'Verify that email.passwordRef points to a configured secret.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// EmailService
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
export class EmailService {
|
|
217
|
+
private readonly deps: EmailServiceDeps;
|
|
218
|
+
|
|
219
|
+
constructor(deps: EmailServiceDeps) {
|
|
220
|
+
this.deps = deps;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Returns a redacted status summary — never includes secret values. */
|
|
224
|
+
getStatus(): { config: EmailConfig; errors: string[]; ready: boolean } {
|
|
225
|
+
const config = readEmailConfig(this.deps.getConfig);
|
|
226
|
+
const errors = validateEmailConfig(config);
|
|
227
|
+
return {
|
|
228
|
+
config: {
|
|
229
|
+
...config,
|
|
230
|
+
// Redact the ref itself for display; keep structural info only
|
|
231
|
+
passwordRef: config.passwordRef ? '[configured]' : '[missing]',
|
|
232
|
+
},
|
|
233
|
+
errors,
|
|
234
|
+
ready: errors.length === 0 && config.enabled,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Fetch up to `limit` inbox summaries.
|
|
240
|
+
* Messages are read via EXAMINE (read-only); unread flag is never modified.
|
|
241
|
+
*/
|
|
242
|
+
async checkInbox(limit = 10): Promise<EmailSummary[]> {
|
|
243
|
+
const config = this.getValidatedConfig();
|
|
244
|
+
const password = await resolveEmailPassword(config.passwordRef, this.deps.secretsManager);
|
|
245
|
+
|
|
246
|
+
const socketFactory = this.deps.imapSocketFactory ?? createImapTlsSocket;
|
|
247
|
+
const socket = await socketFactory(config.imapHost, config.imapPort);
|
|
248
|
+
|
|
249
|
+
const client = new ImapClient({
|
|
250
|
+
socket,
|
|
251
|
+
username: config.username,
|
|
252
|
+
password,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
await client.open();
|
|
257
|
+
const seqNums = await client.searchUnseen();
|
|
258
|
+
const envelopes: ImapEnvelope[] = await client.fetchEnvelopes(seqNums, limit);
|
|
259
|
+
|
|
260
|
+
// Fetch body preview for the newest message only (read-only; BODY.PEEK).
|
|
261
|
+
// Failures are non-fatal — the inbox summary is still returned.
|
|
262
|
+
let newestBodyPreview = '';
|
|
263
|
+
const newestSeq = seqNums[0];
|
|
264
|
+
if (newestSeq !== undefined) {
|
|
265
|
+
try {
|
|
266
|
+
newestBodyPreview = await client.fetchBodyPreview(newestSeq);
|
|
267
|
+
} catch {
|
|
268
|
+
// best-effort: body preview unavailable, proceed without it
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
await client.logout();
|
|
273
|
+
|
|
274
|
+
return envelopes.map((env, idx) => ({
|
|
275
|
+
from: env.from,
|
|
276
|
+
subject: env.subject,
|
|
277
|
+
date: env.date,
|
|
278
|
+
unread: true,
|
|
279
|
+
bodyPreview: idx === 0 ? newestBodyPreview : '',
|
|
280
|
+
}));
|
|
281
|
+
} catch (err) {
|
|
282
|
+
try { await client.logout(); } catch { /* best-effort */ }
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Send a plain-text email.
|
|
289
|
+
* Requires `confirm: true` at the call site — throws without it.
|
|
290
|
+
*/
|
|
291
|
+
async sendMail(opts: SendMailOptions): Promise<void> {
|
|
292
|
+
if (!opts.confirm) {
|
|
293
|
+
throw new Error('sendMail requires confirm: true at the call site');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const config = this.getValidatedConfig();
|
|
297
|
+
const password = await resolveEmailPassword(config.passwordRef, this.deps.secretsManager);
|
|
298
|
+
|
|
299
|
+
const socketFactory = this.deps.smtpSocketFactory ?? this.defaultSmtpSocketFactory(config.smtpPort, config.smtpSecurity);
|
|
300
|
+
const socket = await socketFactory(config.smtpHost, config.smtpPort);
|
|
301
|
+
|
|
302
|
+
const client = new SmtpClient({
|
|
303
|
+
socket,
|
|
304
|
+
hostname: config.smtpHost,
|
|
305
|
+
username: config.username,
|
|
306
|
+
password,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// SEC-1: validate at the service boundary so injection is blocked regardless of
|
|
310
|
+
// which client implementation is used.
|
|
311
|
+
validateSmtpAddress(config.fromAddress, 'from');
|
|
312
|
+
validateSmtpAddress(opts.to, 'to');
|
|
313
|
+
validateSmtpSubject(opts.subject);
|
|
314
|
+
|
|
315
|
+
await client.sendMail({
|
|
316
|
+
from: config.fromAddress,
|
|
317
|
+
to: opts.to,
|
|
318
|
+
subject: opts.subject,
|
|
319
|
+
body: opts.body,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// -------------------------------------------------------------------------
|
|
324
|
+
// Private
|
|
325
|
+
// -------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
private getValidatedConfig(): EmailConfig {
|
|
328
|
+
const config = readEmailConfig(this.deps.getConfig);
|
|
329
|
+
if (!config.enabled) {
|
|
330
|
+
throw new Error('Email is not enabled. Set email.enabled = true in config.');
|
|
331
|
+
}
|
|
332
|
+
const errors = validateEmailConfig(config);
|
|
333
|
+
if (errors.length > 0) {
|
|
334
|
+
throw new Error(`Email config is invalid:\n${errors.map((e) => ` - ${e}`).join('\n')}`);
|
|
335
|
+
}
|
|
336
|
+
return config;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private defaultSmtpSocketFactory(
|
|
340
|
+
port: number,
|
|
341
|
+
security: SmtpSecurityMode,
|
|
342
|
+
): (host: string, p: number) => Promise<Socket> {
|
|
343
|
+
// MIN-2: honor explicit smtpSecurity setting; fall back to port-based auto detection
|
|
344
|
+
if (security === 'tls') return createSmtpTlsSocket;
|
|
345
|
+
if (security === 'starttls') return createSmtpStartTlsSocket;
|
|
346
|
+
// 'auto': use direct TLS on port 465, STARTTLS otherwise
|
|
347
|
+
if (port === 465) return createSmtpTlsSocket;
|
|
348
|
+
return createSmtpStartTlsSocket;
|
|
349
|
+
}
|
|
350
|
+
}
|