@pellux/goodvibes-agent 1.5.5 → 1.5.6
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 +9 -0
- package/README.md +1 -1
- package/dist/package/main.js +94130 -79335
- package/docs/tools-and-commands.md +2 -1
- package/package.json +6 -3
- package/src/agent/autonomy-schedule-format.ts +1 -1
- package/src/agent/autonomy-schedule.ts +3 -3
- package/src/agent/calendar/calendar-oauth-service.ts +324 -0
- package/src/agent/calendar-subscription-registry.ts +497 -0
- package/src/agent/email/email-service.ts +57 -0
- package/src/agent/email/smtp-client.ts +30 -10
- package/src/agent/memory-prompt.ts +143 -12
- package/src/agent/operator-actions.ts +26 -22
- package/src/agent/prompt-context-receipts.ts +45 -18
- package/src/agent/reminder-schedule-format.ts +1 -1
- package/src/agent/reminder-schedule.ts +3 -3
- package/src/agent/routine-schedule-format.ts +2 -2
- package/src/agent/routine-schedule-promotion.ts +5 -5
- package/src/agent/routine-schedule-receipts.ts +2 -2
- package/src/agent/schedule-edit-format.ts +1 -1
- package/src/agent/schedule-edit.ts +5 -5
- package/src/agent/session-registration.ts +281 -0
- package/src/agent/vibe-file.ts +50 -0
- package/src/cli/local-library-command-shared.ts +146 -0
- package/src/cli/management.ts +2 -1
- package/src/cli/personas-command.ts +252 -0
- package/src/cli/resume-relaunch-notice.ts +151 -0
- package/src/cli/routines-command.ts +25 -2
- package/src/cli/service-posture.ts +3 -2
- package/src/cli/skill-bundle-command.ts +175 -0
- package/src/cli/skills-command.ts +309 -0
- package/src/cli/status.ts +43 -7
- package/src/cli/tui-startup.ts +24 -1
- package/src/config/agent-settings-policy.ts +0 -1
- package/src/config/secret-config.ts +4 -0
- package/src/core/conversation-rendering.ts +20 -11
- package/src/core/focus-tracker.ts +41 -0
- package/src/core/system-message-noise.ts +108 -0
- package/src/core/system-message-router.ts +54 -1
- package/src/core/thinking-overlay.ts +83 -0
- package/src/input/agent-workspace-access-command-editor-submission.ts +75 -108
- package/src/input/agent-workspace-access-command-editors.ts +108 -128
- package/src/input/agent-workspace-activation.ts +15 -0
- package/src/input/agent-workspace-basic-command-editor-submission.ts +292 -513
- package/src/input/agent-workspace-basic-command-editors.ts +433 -564
- package/src/input/agent-workspace-calendar-connect-editor.ts +116 -0
- package/src/input/agent-workspace-calendar-oauth-editor.ts +152 -0
- package/src/input/agent-workspace-calendar-subscribe-editor.ts +128 -0
- package/src/input/agent-workspace-categories.ts +7 -2
- package/src/input/agent-workspace-channel-command-editor-submission.ts +38 -52
- package/src/input/agent-workspace-channel-command-editors.ts +42 -46
- package/src/input/agent-workspace-command-editor-engine.ts +133 -0
- package/src/input/agent-workspace-command-editor.ts +4 -0
- package/src/input/agent-workspace-direct-editor-submission.ts +59 -0
- package/src/input/agent-workspace-email-connect-editor.ts +162 -0
- package/src/input/agent-workspace-knowledge-command-editor-submission.ts +52 -81
- package/src/input/agent-workspace-knowledge-command-editors.ts +71 -71
- package/src/input/agent-workspace-library-command-editor-submission.ts +10 -28
- package/src/input/agent-workspace-library-command-editors.ts +16 -2
- package/src/input/agent-workspace-live-counters.ts +55 -0
- package/src/input/agent-workspace-mcp-command-editor-submission.ts +60 -82
- package/src/input/agent-workspace-mcp-command-editors.ts +52 -0
- package/src/input/agent-workspace-media-command-editor-submission.ts +12 -39
- package/src/input/agent-workspace-media-command-editors.ts +10 -8
- package/src/input/agent-workspace-memory-command-editor-submission.ts +76 -151
- package/src/input/agent-workspace-memory-command-editors.ts +116 -141
- package/src/input/agent-workspace-operations-command-editor-submission.ts +131 -184
- package/src/input/agent-workspace-operations-command-editors.ts +150 -162
- package/src/input/agent-workspace-provider-command-editor-submission.ts +60 -106
- package/src/input/agent-workspace-provider-command-editors.ts +58 -68
- package/src/input/agent-workspace-search.ts +8 -1
- package/src/input/agent-workspace-session-command-editor-submission.ts +104 -132
- package/src/input/agent-workspace-session-command-editors.ts +160 -195
- package/src/input/agent-workspace-settings.ts +40 -1
- package/src/input/agent-workspace-skill-bundle-command-editor-submission.ts +55 -71
- package/src/input/agent-workspace-skill-bundle-command-editors.ts +65 -69
- package/src/input/agent-workspace-snapshot-builders.ts +431 -0
- package/src/input/agent-workspace-snapshot-config.ts +43 -0
- package/src/input/agent-workspace-snapshot.ts +198 -432
- package/src/input/agent-workspace-task-command-editor-submission.ts +34 -44
- package/src/input/agent-workspace-task-command-editors.ts +27 -28
- package/src/input/agent-workspace-types.ts +35 -1
- package/src/input/agent-workspace.ts +5 -14
- package/src/input/command-registry.ts +23 -1
- package/src/input/commands/calendar-connect-runtime.ts +226 -0
- package/src/input/commands/calendar-runtime.ts +110 -7
- package/src/input/commands/calendar-subscription-runtime.ts +225 -0
- package/src/input/commands/email-runtime.ts +100 -40
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/network-scan-runtime.ts +75 -0
- package/src/input/commands/operator-actions-runtime.ts +9 -6
- package/src/input/commands/personas-runtime.ts +1 -1
- package/src/input/commands/schedule-runtime.ts +4 -4
- package/src/input/commands/session-content.ts +13 -1
- package/src/input/commands/session-workflow.ts +15 -20
- package/src/input/commands/session.ts +3 -1
- package/src/input/commands/shell-core.ts +20 -6
- package/src/input/commands.ts +2 -2
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +10 -0
- package/src/input/handler-feed.ts +87 -0
- package/src/input/handler-modal-routes.ts +6 -1
- package/src/input/handler.ts +5 -0
- package/src/input/panel-paste-flood-guard.ts +94 -0
- package/src/input/settings-modal-types.ts +5 -3
- package/src/input/settings-modal.ts +21 -0
- package/src/main.ts +36 -36
- package/src/permissions/approval-posture.ts +141 -0
- package/src/renderer/agent-workspace-context-lines.ts +10 -2
- package/src/renderer/compositor.ts +27 -4
- package/src/renderer/diff.ts +61 -18
- package/src/renderer/fullscreen-primitives.ts +37 -18
- package/src/renderer/markdown.ts +20 -10
- package/src/renderer/modal-factory.ts +25 -15
- package/src/renderer/overlay-box.ts +23 -12
- package/src/renderer/process-indicator.ts +8 -3
- package/src/renderer/prompt-content-width.ts +16 -0
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +2 -0
- package/src/renderer/startup-theme-probe.ts +35 -0
- package/src/renderer/status-glyphs.ts +11 -15
- package/src/renderer/system-message.ts +17 -2
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/terminal-bg-probe.ts +373 -0
- package/src/renderer/terminal-escapes.ts +24 -0
- package/src/renderer/theme-mode-config.ts +87 -0
- package/src/renderer/theme.ts +241 -0
- package/src/renderer/thinking.ts +12 -3
- package/src/renderer/tool-call.ts +7 -3
- package/src/renderer/ui-factory.ts +92 -36
- package/src/renderer/ui-primitives.ts +33 -93
- package/src/runtime/bootstrap-core.ts +15 -0
- package/src/runtime/bootstrap-hook-bridge.ts +6 -0
- package/src/runtime/bootstrap-shell.ts +2 -0
- package/src/runtime/bootstrap.ts +68 -5
- package/src/runtime/calendar-boot-refresh.ts +105 -0
- package/src/runtime/lan-scan-consent.ts +253 -0
- package/src/runtime/services.ts +127 -2
- package/src/runtime/session-spine-rest-transport.ts +160 -0
- package/src/runtime/terminal-output-guard.ts +6 -1
- package/src/runtime/ui-services.ts +3 -0
- package/src/shell/agent-workspace-fullscreen.ts +5 -0
- package/src/shell/terminal-focus-mode.ts +120 -0
- package/src/shell/ui-openers.ts +13 -4
- package/src/tools/agent-harness-autonomy-intake.ts +5 -5
- package/src/tools/agent-harness-autonomy-queue.ts +1 -1
- package/src/tools/agent-harness-background-processes.ts +2 -1
- package/src/tools/agent-harness-metadata.ts +16 -8
- package/src/tools/agent-harness-operator-methods.ts +44 -8
- package/src/tools/agent-harness-personal-ops-discovery.ts +57 -10
- package/src/tools/agent-harness-personal-ops-lanes.ts +17 -5
- package/src/tools/agent-harness-personal-ops-operations.ts +7 -7
- package/src/tools/agent-harness-personal-ops-types.ts +6 -0
- package/src/tools/agent-harness-prompt-context.ts +26 -12
- package/src/tools/agent-harness-workspace-actions.ts +4 -0
- package/src/tools/agent-local-registry-args.ts +117 -0
- package/src/tools/agent-local-registry-memory.ts +227 -0
- package/src/tools/agent-local-registry-tool.ts +19 -237
- package/src/tools/agent-operator-briefing-tool.ts +2 -2
- package/src/tools/agent-operator-method-tool.ts +13 -0
- package/src/tools/agent-policy-explanation.ts +39 -4
- package/src/tools/agent-schedule-tool.ts +5 -5
- package/src/utils/terminal-width.ts +98 -1
- package/src/version.ts +1 -1
- package/src/cli/local-library-command.ts +0 -825
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import type { ShellPathService } from '@/runtime/index.ts';
|
|
4
|
+
import {
|
|
5
|
+
SubscriptionStore,
|
|
6
|
+
expandEvent,
|
|
7
|
+
maskFeedUrl,
|
|
8
|
+
DEFAULT_REFRESH_INTERVAL_MS,
|
|
9
|
+
type CalendarEvent,
|
|
10
|
+
type FeedFetcher,
|
|
11
|
+
type SubscriptionHealth,
|
|
12
|
+
} from '@pellux/goodvibes-sdk/platform/calendar';
|
|
13
|
+
import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Agent-side registry for READ-ONLY external calendar SUBSCRIPTIONS (iCalendar
|
|
17
|
+
* feeds — Google "secret address", Outlook published .ics, or any .ics URL).
|
|
18
|
+
*
|
|
19
|
+
* The parse/RRULE/fetch-status engine is the SDK's `platform/calendar`
|
|
20
|
+
* (SubscriptionStore + expandEvent). This class owns the two things the SDK
|
|
21
|
+
* deliberately leaves to the consumer:
|
|
22
|
+
* - PERSISTENCE. Feed events are cached to a JSON store next to the local
|
|
23
|
+
* calendar so `/calendar list` merges them with no network call; subscription
|
|
24
|
+
* metadata (name, cadence, validators, last-status) lives there too.
|
|
25
|
+
* - SECRET STORAGE. The feed URL is secrets-adjacent (a Google secret address
|
|
26
|
+
* grants read access to the calendar), so it is stored ONLY through the
|
|
27
|
+
* SecretsManager under a per-subscription key; the JSON store holds the secret
|
|
28
|
+
* KEY, never the URL. `maskFeedUrl` is used anywhere a URL would be shown.
|
|
29
|
+
*
|
|
30
|
+
* Consent = subscribing. Nothing here fetches a URL the user did not explicitly
|
|
31
|
+
* add. The subscribe verb states what will be fetched and how often before saving.
|
|
32
|
+
*
|
|
33
|
+
* Subscribed events are READ-ONLY: they carry a `sub:<name>:` id namespace so the
|
|
34
|
+
* local-store edit/delete paths can refuse them with a plain reason.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// ──────────────────────────────────────────────────────────────────
|
|
38
|
+
// Types
|
|
39
|
+
// ──────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** The SDK CalendarEvent as cached on disk (structurally identical; JSON-safe). */
|
|
42
|
+
export type CachedFeedEvent = CalendarEvent;
|
|
43
|
+
|
|
44
|
+
interface SubscriptionMeta {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
/** SecretsManager key holding the feed URL. */
|
|
47
|
+
readonly secretKey: string;
|
|
48
|
+
readonly refreshIntervalMs: number;
|
|
49
|
+
readonly lastFetchedAt?: number;
|
|
50
|
+
readonly lastSucceededAt?: number;
|
|
51
|
+
readonly etag?: string;
|
|
52
|
+
readonly lastModified?: string;
|
|
53
|
+
readonly lastResult?: 'ok' | 'not-modified' | 'unreachable' | 'parse-error';
|
|
54
|
+
readonly lastDetail?: string;
|
|
55
|
+
readonly eventCount?: number;
|
|
56
|
+
readonly events: readonly CachedFeedEvent[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface SubscriptionStoreFile {
|
|
60
|
+
readonly version: 1;
|
|
61
|
+
readonly subscriptions: readonly SubscriptionMeta[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A subscription's status as shown by `/calendar subscriptions`. */
|
|
65
|
+
export interface SubscriptionStatus {
|
|
66
|
+
readonly name: string;
|
|
67
|
+
readonly maskedUrl: string;
|
|
68
|
+
readonly health: SubscriptionHealth;
|
|
69
|
+
readonly detail?: string;
|
|
70
|
+
readonly refreshIntervalMs: number;
|
|
71
|
+
readonly lastSucceededAt?: number;
|
|
72
|
+
readonly eventCount: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One subscribed occurrence, flattened for a merged, source-labeled view. */
|
|
76
|
+
export interface SubscribedOccurrence {
|
|
77
|
+
/** Read-only namespaced id: `sub:<subscriptionName>:<uid>`. */
|
|
78
|
+
readonly id: string;
|
|
79
|
+
readonly subscription: string;
|
|
80
|
+
readonly title: string;
|
|
81
|
+
/** 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:mm:ss[Z]'. */
|
|
82
|
+
readonly start: string;
|
|
83
|
+
readonly allDay: boolean;
|
|
84
|
+
readonly location?: string;
|
|
85
|
+
readonly tzid?: string;
|
|
86
|
+
/** True when the source RRULE was not fully expanded — shown as a marker. */
|
|
87
|
+
readonly recurrenceNotFullyExpanded: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type SubscribeResult =
|
|
91
|
+
| { readonly ok: true; readonly name: string; readonly eventCount: number; readonly calendarName?: string; readonly refreshIntervalMs: number }
|
|
92
|
+
| { readonly ok: false; readonly stage: 'fetch' | 'parse' | 'duplicate'; readonly detail: string };
|
|
93
|
+
|
|
94
|
+
export type ValidateResult =
|
|
95
|
+
| { readonly ok: true; readonly derivedName: string; readonly eventCount: number; readonly calendarName?: string }
|
|
96
|
+
| { readonly ok: false; readonly stage: 'fetch' | 'parse'; readonly detail: string };
|
|
97
|
+
|
|
98
|
+
export interface RefreshOutcome {
|
|
99
|
+
readonly name: string;
|
|
100
|
+
readonly outcome: 'updated' | 'not-modified' | 'skipped' | 'unreachable' | 'parse-error';
|
|
101
|
+
readonly detail?: string;
|
|
102
|
+
readonly eventCount?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The SecretsManager surface this registry needs. */
|
|
106
|
+
export interface SubscriptionSecretStore {
|
|
107
|
+
readonly get: (key: string) => Promise<string | null>;
|
|
108
|
+
readonly set: (key: string, value: string) => Promise<void>;
|
|
109
|
+
readonly delete?: (key: string) => Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ──────────────────────────────────────────────────────────────────
|
|
113
|
+
// Helpers
|
|
114
|
+
// ──────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
const STORE_VERSION = 1;
|
|
117
|
+
/** Data older than this multiple of the refresh interval reads as stale. */
|
|
118
|
+
const STALE_MULTIPLE = 2;
|
|
119
|
+
|
|
120
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
121
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function secretKeyForName(name: string): string {
|
|
125
|
+
const slug = name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase();
|
|
126
|
+
return `GOODVIBES_CALENDAR_SUB_${slug || 'FEED'}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type AgentLocalStorePaths = Pick<ShellPathService, 'resolveUserPath'>;
|
|
130
|
+
|
|
131
|
+
export function subscriptionStorePath(shellPaths: AgentLocalStorePaths): string {
|
|
132
|
+
return shellPaths.resolveUserPath(GOODVIBES_AGENT_SURFACE_ROOT, 'calendar', 'subscriptions.json');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The real HTTP fetcher — conditional GET with etag/last-modified, mapped to FeedFetchResult. */
|
|
136
|
+
export function createHttpFeedFetcher(): FeedFetcher {
|
|
137
|
+
return async (req) => {
|
|
138
|
+
try {
|
|
139
|
+
const headers: Record<string, string> = { Accept: 'text/calendar, text/plain, */*' };
|
|
140
|
+
if (req.etag) headers['If-None-Match'] = req.etag;
|
|
141
|
+
if (req.lastModified) headers['If-Modified-Since'] = req.lastModified;
|
|
142
|
+
const res = await fetch(req.url, { headers, redirect: 'follow' });
|
|
143
|
+
if (res.status === 304) {
|
|
144
|
+
return { kind: 'not-modified', ...(res.headers.get('etag') ? { etag: res.headers.get('etag')! } : {}), ...(res.headers.get('last-modified') ? { lastModified: res.headers.get('last-modified')! } : {}) };
|
|
145
|
+
}
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
return { kind: 'error', status: res.status, message: res.statusText || `HTTP ${res.status}` };
|
|
148
|
+
}
|
|
149
|
+
const body = await res.text();
|
|
150
|
+
return { kind: 'ok', body, ...(res.headers.get('etag') ? { etag: res.headers.get('etag')! } : {}), ...(res.headers.get('last-modified') ? { lastModified: res.headers.get('last-modified')! } : {}) };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return { kind: 'error', message: error instanceof Error ? error.message : String(error) };
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ──────────────────────────────────────────────────────────────────
|
|
158
|
+
// Registry
|
|
159
|
+
// ──────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export interface CalendarSubscriptionRegistryOptions {
|
|
162
|
+
readonly storePath: string;
|
|
163
|
+
readonly secrets: SubscriptionSecretStore;
|
|
164
|
+
readonly fetcher: FeedFetcher;
|
|
165
|
+
readonly clock?: () => number;
|
|
166
|
+
readonly defaultRefreshIntervalMs?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export class CalendarSubscriptionRegistry {
|
|
170
|
+
private readonly storePath: string;
|
|
171
|
+
private readonly secrets: SubscriptionSecretStore;
|
|
172
|
+
private readonly fetcher: FeedFetcher;
|
|
173
|
+
private readonly clock: () => number;
|
|
174
|
+
private readonly defaultInterval: number;
|
|
175
|
+
/**
|
|
176
|
+
* F2 fix: every store WRITE (the read-merge-write critical section of
|
|
177
|
+
* subscribe/unsubscribe/refresh) is serialized through this single
|
|
178
|
+
* in-process promise chain, one instance per registry. Network calls
|
|
179
|
+
* (feed fetch, secret-store reads) are NOT held behind this queue — only
|
|
180
|
+
* the final read-fresh/merge/write section is — so a slow refresh never
|
|
181
|
+
* blocks a concurrent subscribe/unsubscribe from making progress; it only
|
|
182
|
+
* ensures the two operations' WRITES never race each other and neither
|
|
183
|
+
* ever blind-overwrites a change the other just made.
|
|
184
|
+
*/
|
|
185
|
+
private mutationQueue: Promise<unknown> = Promise.resolve();
|
|
186
|
+
|
|
187
|
+
public constructor(options: CalendarSubscriptionRegistryOptions) {
|
|
188
|
+
this.storePath = options.storePath;
|
|
189
|
+
this.secrets = options.secrets;
|
|
190
|
+
this.fetcher = options.fetcher;
|
|
191
|
+
this.clock = options.clock ?? (() => Date.now());
|
|
192
|
+
this.defaultInterval = options.defaultRefreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Runs `fn` after every previously queued mutation has settled, chaining
|
|
197
|
+
* this call onto the queue regardless of whether earlier calls resolved or
|
|
198
|
+
* rejected (a failed mutation must never wedge the queue for the ones
|
|
199
|
+
* behind it). Returns `fn`'s own result/rejection to its caller.
|
|
200
|
+
*/
|
|
201
|
+
private withMutationLock<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
202
|
+
const run = this.mutationQueue.then(fn, fn);
|
|
203
|
+
this.mutationQueue = run.then(
|
|
204
|
+
() => undefined,
|
|
205
|
+
() => undefined,
|
|
206
|
+
);
|
|
207
|
+
return run;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
public static create(shellPaths: AgentLocalStorePaths, secrets: SubscriptionSecretStore, fetcher?: FeedFetcher): CalendarSubscriptionRegistry {
|
|
211
|
+
return new CalendarSubscriptionRegistry({ storePath: subscriptionStorePath(shellPaths), secrets, fetcher: fetcher ?? createHttpFeedFetcher() });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Fetch a feed WITHOUT saving it — the subscribe preview / wizard validate step. */
|
|
215
|
+
public async validate(url: string, requestedName?: string): Promise<ValidateResult> {
|
|
216
|
+
const store = new SubscriptionStore({ fetcher: this.fetcher, clock: this.clock });
|
|
217
|
+
const res = await store.validateByFetch(url, requestedName);
|
|
218
|
+
if (!res.ok) return { ok: false, stage: res.stage, detail: res.detail };
|
|
219
|
+
return { ok: true, derivedName: res.derivedName, eventCount: res.eventCount, ...(res.calendarName !== undefined ? { calendarName: res.calendarName } : {}) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Subscribe: fetch once, derive the name, store the URL as a secret, and cache
|
|
224
|
+
* events. The network fetch/validate (`store.add`) runs unlocked — it is the
|
|
225
|
+
* slow part and does not need exclusive access to the store — but the
|
|
226
|
+
* duplicate-name check, the secret write, and the file write all happen
|
|
227
|
+
* inside one locked read-merge-write section (F2) so a concurrent refresh
|
|
228
|
+
* finishing its own write in between can never cause this subscribe to be
|
|
229
|
+
* silently reverted: the section always re-reads the store fresh right
|
|
230
|
+
* before writing.
|
|
231
|
+
*/
|
|
232
|
+
public async subscribe(url: string, requestedName?: string, refreshIntervalMs?: number): Promise<SubscribeResult> {
|
|
233
|
+
const store = new SubscriptionStore({ fetcher: this.fetcher, clock: this.clock, ...(refreshIntervalMs !== undefined ? { defaultRefreshIntervalMs: refreshIntervalMs } : { defaultRefreshIntervalMs: this.defaultInterval }) });
|
|
234
|
+
const result = await store.add({ url, ...(requestedName !== undefined ? { name: requestedName } : {}), ...(refreshIntervalMs !== undefined ? { refreshIntervalMs } : {}) });
|
|
235
|
+
if (!result.ok) return { ok: false, stage: result.stage, detail: result.detail };
|
|
236
|
+
const sub = result.subscription;
|
|
237
|
+
const events = store.events(sub.name);
|
|
238
|
+
|
|
239
|
+
return this.withMutationLock(async () => {
|
|
240
|
+
const file = this.readStore();
|
|
241
|
+
if (file.subscriptions.some((s) => s.name === sub.name)) {
|
|
242
|
+
return { ok: false, stage: 'duplicate', detail: `A subscription named '${sub.name}' already exists — unsubscribe first or pass a different --name.` };
|
|
243
|
+
}
|
|
244
|
+
const secretKey = secretKeyForName(sub.name);
|
|
245
|
+
await this.secrets.set(secretKey, url);
|
|
246
|
+
const meta: SubscriptionMeta = {
|
|
247
|
+
name: sub.name,
|
|
248
|
+
secretKey,
|
|
249
|
+
refreshIntervalMs: sub.refreshIntervalMs,
|
|
250
|
+
...(sub.lastFetchedAt !== undefined ? { lastFetchedAt: sub.lastFetchedAt } : {}),
|
|
251
|
+
...(sub.lastSucceededAt !== undefined ? { lastSucceededAt: sub.lastSucceededAt } : {}),
|
|
252
|
+
...(sub.etag !== undefined ? { etag: sub.etag } : {}),
|
|
253
|
+
...(sub.lastModified !== undefined ? { lastModified: sub.lastModified } : {}),
|
|
254
|
+
lastResult: 'ok',
|
|
255
|
+
...(sub.detail !== undefined ? { lastDetail: sub.detail } : {}),
|
|
256
|
+
eventCount: events.length,
|
|
257
|
+
events: [...events],
|
|
258
|
+
};
|
|
259
|
+
this.writeStore({ version: STORE_VERSION, subscriptions: [...file.subscriptions, meta] });
|
|
260
|
+
return { ok: true as const, name: sub.name, eventCount: events.length, refreshIntervalMs: sub.refreshIntervalMs };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Remove a subscription, its cached events, and its stored URL secret. The
|
|
266
|
+
* whole read-find-delete-write body is one locked section (F2): small and
|
|
267
|
+
* fast, so there is no benefit to splitting it, and it guarantees the write
|
|
268
|
+
* is always based on a store read taken after every earlier queued mutation
|
|
269
|
+
* (including a refresh's write) has already landed.
|
|
270
|
+
*/
|
|
271
|
+
public async unsubscribe(name: string): Promise<boolean> {
|
|
272
|
+
return this.withMutationLock(async () => {
|
|
273
|
+
const file = this.readStore();
|
|
274
|
+
const target = file.subscriptions.find((s) => s.name.toLowerCase() === name.trim().toLowerCase());
|
|
275
|
+
if (!target) return false;
|
|
276
|
+
if (this.secrets.delete) {
|
|
277
|
+
try { await this.secrets.delete(target.secretKey); } catch { /* best-effort secret cleanup */ }
|
|
278
|
+
}
|
|
279
|
+
this.writeStore({ version: STORE_VERSION, subscriptions: file.subscriptions.filter((s) => s !== target) });
|
|
280
|
+
return true;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Refresh one named subscription (or all when name omitted). Network, on
|
|
286
|
+
* demand / on boot.
|
|
287
|
+
*
|
|
288
|
+
* F2 fix: the per-target network fetches run unlocked (so a concurrent
|
|
289
|
+
* subscribe/unsubscribe is never blocked behind them), but the write is a
|
|
290
|
+
* single locked section that re-reads the store fresh and merges each
|
|
291
|
+
* refreshed record onto that fresh read — never onto the stale snapshot
|
|
292
|
+
* this method started with. Concretely: `merged = freshRead.map(record =>
|
|
293
|
+
* refreshedByName.get(record.name) ?? record)`. Because the merge maps
|
|
294
|
+
* over the FRESH list rather than over this call's original target list, a
|
|
295
|
+
* record removed by a concurrent unsubscribe is simply absent from the
|
|
296
|
+
* fresh list and is never resurrected, and a record added by a concurrent
|
|
297
|
+
* subscribe is already present in the fresh list and passes through
|
|
298
|
+
* untouched. Two concurrent refresh() calls still only ever commit through
|
|
299
|
+
* this same locked section one at a time, so neither can double-write or
|
|
300
|
+
* clobber the other's result.
|
|
301
|
+
*/
|
|
302
|
+
public async refresh(name?: string, opts: { force?: boolean } = {}): Promise<RefreshOutcome[]> {
|
|
303
|
+
const file = this.readStore();
|
|
304
|
+
const targets = name
|
|
305
|
+
? file.subscriptions.filter((s) => s.name.toLowerCase() === name.trim().toLowerCase())
|
|
306
|
+
: file.subscriptions;
|
|
307
|
+
if (targets.length === 0) return [];
|
|
308
|
+
|
|
309
|
+
const refreshedByName = new Map<string, SubscriptionMeta>();
|
|
310
|
+
const outcomes: RefreshOutcome[] = [];
|
|
311
|
+
for (const target of targets) {
|
|
312
|
+
const url = await this.secrets.get(target.secretKey);
|
|
313
|
+
if (url === null) {
|
|
314
|
+
outcomes.push({ name: target.name, outcome: 'unreachable', detail: 'Stored feed URL is missing from the secret manager — unsubscribe and re-add.' });
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const store = new SubscriptionStore({ fetcher: this.fetcher, clock: this.clock });
|
|
318
|
+
store.restore([{ name: target.name, url, refreshIntervalMs: target.refreshIntervalMs, ...(target.lastFetchedAt !== undefined ? { lastFetchedAt: target.lastFetchedAt } : {}), ...(target.lastSucceededAt !== undefined ? { lastSucceededAt: target.lastSucceededAt } : {}), ...(target.etag !== undefined ? { etag: target.etag } : {}), ...(target.lastModified !== undefined ? { lastModified: target.lastModified } : {}) }]);
|
|
319
|
+
const report = (await store.refresh(target.name, { force: opts.force ?? true }))!;
|
|
320
|
+
const sub = store.get(target.name)!;
|
|
321
|
+
const events = report.outcome === 'updated' ? store.events(target.name) : target.events;
|
|
322
|
+
refreshedByName.set(target.name, {
|
|
323
|
+
name: target.name,
|
|
324
|
+
secretKey: target.secretKey,
|
|
325
|
+
refreshIntervalMs: sub.refreshIntervalMs,
|
|
326
|
+
...(sub.lastFetchedAt !== undefined ? { lastFetchedAt: sub.lastFetchedAt } : {}),
|
|
327
|
+
...(sub.lastSucceededAt !== undefined ? { lastSucceededAt: sub.lastSucceededAt } : {}),
|
|
328
|
+
...(sub.etag !== undefined ? { etag: sub.etag } : {}),
|
|
329
|
+
...(sub.lastModified !== undefined ? { lastModified: sub.lastModified } : {}),
|
|
330
|
+
lastResult: report.outcome === 'skipped' ? target.lastResult : this.resultOf(report.outcome),
|
|
331
|
+
...(report.detail !== undefined ? { lastDetail: report.detail } : {}),
|
|
332
|
+
eventCount: events.length,
|
|
333
|
+
events: [...events],
|
|
334
|
+
});
|
|
335
|
+
outcomes.push({ name: target.name, outcome: report.outcome, ...(report.detail !== undefined ? { detail: report.detail } : {}), eventCount: events.length });
|
|
336
|
+
}
|
|
337
|
+
if (refreshedByName.size === 0) return outcomes;
|
|
338
|
+
|
|
339
|
+
await this.withMutationLock(() => {
|
|
340
|
+
const fresh = this.readStore();
|
|
341
|
+
const merged = fresh.subscriptions.map((s) => refreshedByName.get(s.name) ?? s);
|
|
342
|
+
this.writeStore({ version: STORE_VERSION, subscriptions: merged });
|
|
343
|
+
});
|
|
344
|
+
return outcomes;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Subscription statuses for `/calendar subscriptions` — no network, masked URLs. */
|
|
348
|
+
public async statuses(): Promise<readonly SubscriptionStatus[]> {
|
|
349
|
+
const file = this.readStore();
|
|
350
|
+
const now = this.clock();
|
|
351
|
+
const out: SubscriptionStatus[] = [];
|
|
352
|
+
for (const s of file.subscriptions) {
|
|
353
|
+
const url = await this.secrets.get(s.secretKey);
|
|
354
|
+
out.push({
|
|
355
|
+
name: s.name,
|
|
356
|
+
maskedUrl: url ? maskFeedUrl(url) : '(url missing from secret manager)',
|
|
357
|
+
health: this.healthOf(s, now),
|
|
358
|
+
...(this.detailOf(s, now) !== undefined ? { detail: this.detailOf(s, now)! } : {}),
|
|
359
|
+
refreshIntervalMs: s.refreshIntervalMs,
|
|
360
|
+
...(s.lastSucceededAt !== undefined ? { lastSucceededAt: s.lastSucceededAt } : {}),
|
|
361
|
+
eventCount: s.eventCount ?? s.events.length,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
public hasAny(): boolean {
|
|
368
|
+
return this.readStore().subscriptions.length > 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
public names(): readonly string[] {
|
|
372
|
+
return this.readStore().subscriptions.map((s) => s.name);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* One row per cached subscribed event (the seed, unexpanded) — the merged
|
|
377
|
+
* `/calendar list` view. A recurring event is marked so the reader knows more
|
|
378
|
+
* occurrences exist; `occurrencesInWindow` is the date-windowed expansion.
|
|
379
|
+
*/
|
|
380
|
+
public seeds(): readonly SubscribedOccurrence[] {
|
|
381
|
+
const file = this.readStore();
|
|
382
|
+
const out: SubscribedOccurrence[] = [];
|
|
383
|
+
for (const s of file.subscriptions) {
|
|
384
|
+
for (const event of s.events) {
|
|
385
|
+
const recurring = event.recurrence !== undefined;
|
|
386
|
+
out.push({
|
|
387
|
+
id: `sub:${s.name}:${event.uid}`,
|
|
388
|
+
subscription: s.name,
|
|
389
|
+
title: event.summary,
|
|
390
|
+
start: event.start.value,
|
|
391
|
+
allDay: event.start.kind === 'date',
|
|
392
|
+
...(event.location !== undefined ? { location: event.location } : {}),
|
|
393
|
+
...(event.start.zone === 'tzid' && event.start.tzid !== undefined ? { tzid: event.start.tzid } : {}),
|
|
394
|
+
recurrenceNotFullyExpanded: recurring && event.recurrence?.expansion === 'unsupported',
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return out.sort((a, b) => a.start.localeCompare(b.start));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* All subscribed occurrences within [fromDate, toDate] (inclusive, 'YYYY-MM-DD'),
|
|
403
|
+
* flattened and source-labeled for a merged read-only view. Recurring events are
|
|
404
|
+
* expanded through the SDK; an unsupported RRULE surfaces its seed with a marker.
|
|
405
|
+
*/
|
|
406
|
+
public occurrencesInWindow(fromDate: string, toDate: string): readonly SubscribedOccurrence[] {
|
|
407
|
+
const file = this.readStore();
|
|
408
|
+
const out: SubscribedOccurrence[] = [];
|
|
409
|
+
for (const s of file.subscriptions) {
|
|
410
|
+
for (const event of s.events) {
|
|
411
|
+
const occ = expandEvent(event, { from: fromDate, to: toDate });
|
|
412
|
+
const notExpanded = event.recurrence?.expansion === 'unsupported';
|
|
413
|
+
for (const o of occ) {
|
|
414
|
+
out.push({
|
|
415
|
+
id: `sub:${s.name}:${event.uid}`,
|
|
416
|
+
subscription: s.name,
|
|
417
|
+
title: event.summary,
|
|
418
|
+
start: o.start,
|
|
419
|
+
allDay: event.start.kind === 'date',
|
|
420
|
+
...(event.location !== undefined ? { location: event.location } : {}),
|
|
421
|
+
...(event.start.zone === 'tzid' && event.start.tzid !== undefined ? { tzid: event.start.tzid } : {}),
|
|
422
|
+
recurrenceNotFullyExpanded: notExpanded,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return out.sort((a, b) => a.start.localeCompare(b.start));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── internals ────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
private resultOf(outcome: RefreshOutcome['outcome']): SubscriptionMeta['lastResult'] {
|
|
433
|
+
if (outcome === 'updated') return 'ok';
|
|
434
|
+
if (outcome === 'not-modified') return 'not-modified';
|
|
435
|
+
if (outcome === 'unreachable') return 'unreachable';
|
|
436
|
+
if (outcome === 'parse-error') return 'parse-error';
|
|
437
|
+
return 'ok';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private healthOf(s: SubscriptionMeta, now: number): SubscriptionHealth {
|
|
441
|
+
if (s.lastResult === undefined) return 'never-fetched';
|
|
442
|
+
if (s.lastResult === 'unreachable') return 'unreachable';
|
|
443
|
+
if (s.lastResult === 'parse-error') return 'parse-error';
|
|
444
|
+
if (s.lastSucceededAt === undefined) return 'never-fetched';
|
|
445
|
+
if (now - s.lastSucceededAt > s.refreshIntervalMs * STALE_MULTIPLE) return 'stale';
|
|
446
|
+
return 'ok';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private detailOf(s: SubscriptionMeta, now: number): string | undefined {
|
|
450
|
+
if (this.healthOf(s, now) === 'stale' && s.lastSucceededAt !== undefined) {
|
|
451
|
+
const ageMin = Math.round((now - s.lastSucceededAt) / 60000);
|
|
452
|
+
return `Last updated ${ageMin} min ago (past the ${Math.round(s.refreshIntervalMs / 60000)} min refresh window).`;
|
|
453
|
+
}
|
|
454
|
+
return s.lastDetail;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private readStore(): SubscriptionStoreFile {
|
|
458
|
+
if (!existsSync(this.storePath)) return { version: STORE_VERSION, subscriptions: [] };
|
|
459
|
+
try {
|
|
460
|
+
const parsed: unknown = JSON.parse(readFileSync(this.storePath, 'utf-8'));
|
|
461
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.subscriptions)) return { version: STORE_VERSION, subscriptions: [] };
|
|
462
|
+
const subscriptions = parsed.subscriptions.filter(isRecord).map(coerceMeta).filter((m): m is SubscriptionMeta => m !== null);
|
|
463
|
+
return { version: STORE_VERSION, subscriptions };
|
|
464
|
+
} catch (error) {
|
|
465
|
+
throw new Error(`Could not read calendar subscription store: ${error instanceof Error ? error.message : String(error)}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private writeStore(file: SubscriptionStoreFile): void {
|
|
470
|
+
mkdirSync(dirname(this.storePath), { recursive: true });
|
|
471
|
+
const tmp = `${this.storePath}.tmp`;
|
|
472
|
+
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, 'utf-8');
|
|
473
|
+
renameSync(tmp, this.storePath);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function coerceMeta(value: Record<string, unknown>): SubscriptionMeta | null {
|
|
478
|
+
const name = typeof value.name === 'string' ? value.name.trim() : '';
|
|
479
|
+
const secretKey = typeof value.secretKey === 'string' ? value.secretKey : '';
|
|
480
|
+
if (!name || !secretKey) return null;
|
|
481
|
+
const refreshIntervalMs = typeof value.refreshIntervalMs === 'number' ? value.refreshIntervalMs : DEFAULT_REFRESH_INTERVAL_MS;
|
|
482
|
+
const events = Array.isArray(value.events) ? (value.events.filter(isRecord) as unknown as CachedFeedEvent[]) : [];
|
|
483
|
+
const lastResult = value.lastResult === 'ok' || value.lastResult === 'not-modified' || value.lastResult === 'unreachable' || value.lastResult === 'parse-error' ? value.lastResult : undefined;
|
|
484
|
+
return {
|
|
485
|
+
name,
|
|
486
|
+
secretKey,
|
|
487
|
+
refreshIntervalMs,
|
|
488
|
+
...(typeof value.lastFetchedAt === 'number' ? { lastFetchedAt: value.lastFetchedAt } : {}),
|
|
489
|
+
...(typeof value.lastSucceededAt === 'number' ? { lastSucceededAt: value.lastSucceededAt } : {}),
|
|
490
|
+
...(typeof value.etag === 'string' ? { etag: value.etag } : {}),
|
|
491
|
+
...(typeof value.lastModified === 'string' ? { lastModified: value.lastModified } : {}),
|
|
492
|
+
...(lastResult !== undefined ? { lastResult } : {}),
|
|
493
|
+
...(typeof value.lastDetail === 'string' ? { lastDetail: value.lastDetail } : {}),
|
|
494
|
+
...(typeof value.eventCount === 'number' ? { eventCount: value.eventCount } : {}),
|
|
495
|
+
events,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
@@ -103,6 +103,18 @@ export interface EmailSummary {
|
|
|
103
103
|
readonly bodyPreview: string;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Result of a connection verification pass (a connect-wizard "test connection"
|
|
108
|
+
* step). Never includes the raw password; `error` messages come from the
|
|
109
|
+
* underlying client's plain-language exceptions.
|
|
110
|
+
*/
|
|
111
|
+
export interface EmailConnectionTestResult {
|
|
112
|
+
readonly ok: boolean;
|
|
113
|
+
/** Which stage failed, when ok is false. 'config' means validation failed before any connection was attempted. */
|
|
114
|
+
readonly stage?: 'config' | 'imap' | 'smtp';
|
|
115
|
+
readonly error?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
export interface SendMailOptions {
|
|
107
119
|
readonly to: string;
|
|
108
120
|
readonly subject: string;
|
|
@@ -284,6 +296,51 @@ export class EmailService {
|
|
|
284
296
|
}
|
|
285
297
|
}
|
|
286
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Verify the configured IMAP and SMTP connections without sending mail or
|
|
301
|
+
* reading the inbox — a real connectivity + authentication check for a
|
|
302
|
+
* connect-wizard "test connection" step. Does not require config.enabled;
|
|
303
|
+
* callers that want to gate readiness on enabled should check separately.
|
|
304
|
+
*
|
|
305
|
+
* Never throws — returns a result describing which stage (if any) failed,
|
|
306
|
+
* with a plain-language error message. Never includes the raw password.
|
|
307
|
+
*/
|
|
308
|
+
async testConnection(): Promise<EmailConnectionTestResult> {
|
|
309
|
+
const config = readEmailConfig(this.deps.getConfig);
|
|
310
|
+
const errors = validateEmailConfig(config);
|
|
311
|
+
if (errors.length > 0) {
|
|
312
|
+
return { ok: false, stage: 'config', error: errors.join('; ') };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let password: string;
|
|
316
|
+
try {
|
|
317
|
+
password = await resolveEmailPassword(config.passwordRef, this.deps.secretsManager);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return { ok: false, stage: 'config', error: err instanceof Error ? err.message : String(err) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const socketFactory = this.deps.imapSocketFactory ?? createImapTlsSocket;
|
|
324
|
+
const socket = await socketFactory(config.imapHost, config.imapPort);
|
|
325
|
+
const client = new ImapClient({ socket, username: config.username, password });
|
|
326
|
+
await client.open();
|
|
327
|
+
await client.logout();
|
|
328
|
+
} catch (err) {
|
|
329
|
+
return { ok: false, stage: 'imap', error: err instanceof Error ? err.message : String(err) };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const socketFactory = this.deps.smtpSocketFactory ?? this.defaultSmtpSocketFactory(config.smtpPort, config.smtpSecurity);
|
|
334
|
+
const socket = await socketFactory(config.smtpHost, config.smtpPort);
|
|
335
|
+
const client = new SmtpClient({ socket, hostname: config.smtpHost, username: config.username, password });
|
|
336
|
+
await client.verifyAuth();
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return { ok: false, stage: 'smtp', error: err instanceof Error ? err.message : String(err) };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { ok: true };
|
|
342
|
+
}
|
|
343
|
+
|
|
287
344
|
/**
|
|
288
345
|
* Send a plain-text email.
|
|
289
346
|
* Requires `confirm: true` at the call site — throws without it.
|
|
@@ -245,6 +245,22 @@ export class SmtpClient {
|
|
|
245
245
|
this.options = options;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Connect, negotiate EHLO, and authenticate — then QUIT without sending any
|
|
250
|
+
* mail. Used to verify SMTP credentials/host reachability (a connect-wizard
|
|
251
|
+
* "test connection" step) without the side effect of an actual send.
|
|
252
|
+
* Throws with a plain-language message on any failure stage.
|
|
253
|
+
*/
|
|
254
|
+
async verifyAuth(): Promise<void> {
|
|
255
|
+
const { hostname, username, password } = this.options;
|
|
256
|
+
const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
257
|
+
const session = new SmtpSession(this.options.socket, timeoutMs);
|
|
258
|
+
const { capabilities } = await this.greetAndEhlo(session, hostname);
|
|
259
|
+
await this.authenticate(session, capabilities, username, password);
|
|
260
|
+
await session.send('QUIT');
|
|
261
|
+
session.destroy();
|
|
262
|
+
}
|
|
263
|
+
|
|
248
264
|
/**
|
|
249
265
|
* Send a plain-text email.
|
|
250
266
|
* Callers must ensure the caller-side confirms the send before calling this.
|
|
@@ -253,16 +269,7 @@ export class SmtpClient {
|
|
|
253
269
|
const { hostname, username, password } = this.options;
|
|
254
270
|
const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
255
271
|
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());
|
|
272
|
+
const { capabilities } = await this.greetAndEhlo(session, hostname);
|
|
266
273
|
|
|
267
274
|
// AUTH
|
|
268
275
|
await this.authenticate(session, capabilities, username, password);
|
|
@@ -307,6 +314,19 @@ export class SmtpClient {
|
|
|
307
314
|
// Private
|
|
308
315
|
// -------------------------------------------------------------------------
|
|
309
316
|
|
|
317
|
+
/** Read the server greeting and negotiate EHLO. Shared by sendMail and verifyAuth. */
|
|
318
|
+
private async greetAndEhlo(
|
|
319
|
+
session: SmtpSession,
|
|
320
|
+
hostname: string,
|
|
321
|
+
): Promise<{ capabilities: readonly string[] }> {
|
|
322
|
+
const greeting = await session.readResponse();
|
|
323
|
+
if (greeting.code !== 220) {
|
|
324
|
+
throw new Error(`SMTP unexpected greeting: ${greeting.lines.join(' | ')}`);
|
|
325
|
+
}
|
|
326
|
+
const ehlo = await session.cmd(`EHLO ${hostname}`, 250);
|
|
327
|
+
return { capabilities: ehlo.lines.map((l) => l.slice(4).trim().toUpperCase()) };
|
|
328
|
+
}
|
|
329
|
+
|
|
310
330
|
private async authenticate(
|
|
311
331
|
session: SmtpSession,
|
|
312
332
|
capabilities: readonly string[],
|