@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
@@ -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
- return parseStore(readFileSync(this.storePath, 'utf-8'));
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
+ }