@pellux/goodvibes-tui 1.24.1 → 1.25.0
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 +24 -0
- package/README.md +1 -1
- package/bin/goodvibes-daemon +24 -0
- package/docs/foundation-artifacts/operator-contract.json +3237 -1096
- package/package.json +7 -7
- package/src/config/goodvibes-home.ts +139 -0
- package/src/daemon/cli.ts +17 -6
- package/src/daemon/handlers/index.ts +7 -6
- package/src/input/commands/calendar-runtime.ts +245 -0
- package/src/input/commands/connection-status.ts +258 -0
- package/src/input/commands/mail-runtime.ts +267 -0
- package/src/input/commands/review-runtime.ts +13 -1
- package/src/input/commands.ts +4 -0
- package/src/input/settings-modal-connections.ts +102 -0
- package/src/input/settings-modal-types.ts +2 -1
- package/src/input/settings-modal.ts +27 -0
- package/src/main.ts +7 -2
- package/src/renderer/settings-modal-connections.ts +63 -0
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +5 -0
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/context.ts +9 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +0 -4
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/services.ts +21 -24
- package/src/shell/ui-openers.ts +7 -0
- package/src/verification/verification-ledger.ts +58 -0
- package/src/version.ts +1 -1
- package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
- package/src/daemon/handlers/calendar/ics.ts +0 -556
- package/src/daemon/handlers/calendar/index.ts +0 -316
- package/src/daemon/handlers/email/config.ts +0 -164
- package/src/daemon/handlers/email/imap-connector.ts +0 -441
- package/src/daemon/handlers/email/imap-parsing.ts +0 -499
- package/src/daemon/handlers/email/index.ts +0 -43
- package/src/daemon/handlers/email/read-handlers.ts +0 -80
- package/src/daemon/handlers/email/runtime.ts +0 -140
- package/src/daemon/handlers/email/smtp-connector.ts +0 -557
- package/src/daemon/handlers/email/validation.ts +0 -147
- package/src/daemon/handlers/email/write-handlers.ts +0 -133
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
// Handler registration for the CalDAV calendar surface.
|
|
2
|
-
//
|
|
3
|
-
// Attaches host handlers to the five SDK-registered calendar gateway method
|
|
4
|
-
// descriptors BY ID (the SDK auto-registers them with handler:undefined). No
|
|
5
|
-
// method id, descriptor, or schema is authored here — `registerCatalogHandlers`
|
|
6
|
-
// looks up the canonical descriptor via `catalog.get(id)` and re-registers it
|
|
7
|
-
// with the wrapped handler. Method IDs handled (exactly):
|
|
8
|
-
// calendar.events.list read:calendar no confirm
|
|
9
|
-
// calendar.events.get read:calendar no confirm
|
|
10
|
-
// calendar.events.create write:calendar confirm:true + explicitUserRequest
|
|
11
|
-
// calendar.ics.import write:calendar confirm:true + explicitUserRequest
|
|
12
|
-
// calendar.ics.export read:calendar no confirm
|
|
13
|
-
//
|
|
14
|
-
// SECURITY: CalDAV credentials and authenticated URLs NEVER appear in any
|
|
15
|
-
// response. `calendarId` is a logical id. Attendees are surfaced as display
|
|
16
|
-
// names only (no raw addresses). Outputs are stripped to exactly the SDK output
|
|
17
|
-
// schema fields (the SDK schemas set additionalProperties:false).
|
|
18
|
-
|
|
19
|
-
import { HandlerError } from '../errors.ts';
|
|
20
|
-
import type { HandlerContext } from '../context.ts';
|
|
21
|
-
import {
|
|
22
|
-
registerCatalogHandlers,
|
|
23
|
-
type CatalogHandlerEntry,
|
|
24
|
-
type TypedHandler,
|
|
25
|
-
type Unregister,
|
|
26
|
-
} from '../register.ts';
|
|
27
|
-
import {
|
|
28
|
-
createCalDavClient,
|
|
29
|
-
resolveCalDavConfig,
|
|
30
|
-
type CalDavClient,
|
|
31
|
-
type CalDavConfigContext,
|
|
32
|
-
type CalDavEvent,
|
|
33
|
-
} from './caldav-client.ts';
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The exact set of method IDs this surface publishes — a single source of truth
|
|
37
|
-
* for the calendar capability set, consumed by integration wiring and tests.
|
|
38
|
-
*/
|
|
39
|
-
export const CALENDAR_METHOD_IDS = [
|
|
40
|
-
'calendar.events.list',
|
|
41
|
-
'calendar.events.get',
|
|
42
|
-
'calendar.events.create',
|
|
43
|
-
'calendar.ics.import',
|
|
44
|
-
'calendar.ics.export',
|
|
45
|
-
] as const;
|
|
46
|
-
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
// Client factory injection (real client by default; tests inject a stub).
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
|
|
51
|
-
export type CalDavClientFactory = (ctx: CalDavConfigContext) => Promise<CalDavClient>;
|
|
52
|
-
|
|
53
|
-
const realClientFactory: CalDavClientFactory = async (ctx) => {
|
|
54
|
-
const config = await resolveCalDavConfig(ctx);
|
|
55
|
-
return createCalDavClient({ config });
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export interface RegisterCalendarOptions {
|
|
59
|
-
/** Override the CalDAV client factory (for tests). */
|
|
60
|
-
clientFactory?: CalDavClientFactory;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
// Input validation helpers
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
function asRecord(body: unknown): Record<string, unknown> {
|
|
68
|
-
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
69
|
-
throw new HandlerError('Request body must be an object.', 'CALENDAR_BAD_INPUT', 400);
|
|
70
|
-
}
|
|
71
|
-
return body as Record<string, unknown>;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
|
|
75
|
-
const value = record[key];
|
|
76
|
-
if (value === undefined || value === null) return undefined;
|
|
77
|
-
if (typeof value !== 'string') {
|
|
78
|
-
throw new HandlerError(`Field '${key}' must be a string.`, 'CALENDAR_BAD_INPUT', 400);
|
|
79
|
-
}
|
|
80
|
-
const trimmed = value.trim();
|
|
81
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function requiredString(record: Record<string, unknown>, key: string): string {
|
|
85
|
-
const value = optionalString(record, key);
|
|
86
|
-
if (value === undefined) {
|
|
87
|
-
throw new HandlerError(`Field '${key}' is required.`, 'CALENDAR_BAD_INPUT', 400);
|
|
88
|
-
}
|
|
89
|
-
return value;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
|
93
|
-
const value = record[key];
|
|
94
|
-
if (value === undefined || value === null) return undefined;
|
|
95
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
96
|
-
throw new HandlerError(`Field '${key}' must be a number.`, 'CALENDAR_BAD_INPUT', 400);
|
|
97
|
-
}
|
|
98
|
-
return value;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function optionalStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
|
|
102
|
-
const value = record[key];
|
|
103
|
-
if (value === undefined || value === null) return undefined;
|
|
104
|
-
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
105
|
-
throw new HandlerError(`Field '${key}' must be an array of strings.`, 'CALENDAR_BAD_INPUT', 400);
|
|
106
|
-
}
|
|
107
|
-
return (value as string[]).map((item) => item.trim()).filter((item) => item.length > 0);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function validateIsoDate(value: string, field: string): string {
|
|
111
|
-
if (Number.isNaN(new Date(value).getTime())) {
|
|
112
|
-
throw new HandlerError(`Field '${field}' must be a valid ISO-8601 date.`, 'CALENDAR_BAD_INPUT', 400);
|
|
113
|
-
}
|
|
114
|
-
return value;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
// Response mapping (credential-free, PII-stripped, schema-exact)
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Wire shape for `calendar.events.list` items — EXACTLY the SDK
|
|
123
|
-
* CALENDAR_EVENT_SUMMARY_SCHEMA (additionalProperties:false). Optional fields
|
|
124
|
-
* are present only when populated. `calendarId`/`allDay`/organizer are NOT in
|
|
125
|
-
* the SDK schema and are intentionally dropped.
|
|
126
|
-
*/
|
|
127
|
-
export interface CalendarEventSummary {
|
|
128
|
-
id: string;
|
|
129
|
-
title: string;
|
|
130
|
-
start: string;
|
|
131
|
-
end: string;
|
|
132
|
-
location?: string;
|
|
133
|
-
description?: string;
|
|
134
|
-
attendees?: string[];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function displayAttendees(event: CalDavEvent): string[] {
|
|
138
|
-
return event.attendees.map((a) => a.displayName).filter((name) => name.length > 0);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function toSummary(event: CalDavEvent): CalendarEventSummary {
|
|
142
|
-
const attendees = displayAttendees(event);
|
|
143
|
-
const summary: CalendarEventSummary = {
|
|
144
|
-
id: event.href || event.uid,
|
|
145
|
-
title: event.summary,
|
|
146
|
-
start: event.start,
|
|
147
|
-
end: event.end,
|
|
148
|
-
};
|
|
149
|
-
if (event.location !== undefined) summary.location = event.location;
|
|
150
|
-
if (event.description !== undefined) summary.description = event.description;
|
|
151
|
-
if (attendees.length > 0) summary.attendees = attendees;
|
|
152
|
-
return summary;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Wire shape for `calendar.events.get` — EXACTLY the SDK
|
|
157
|
-
* CALENDAR_EVENT_DETAIL_SCHEMA (additionalProperties:false), returned directly
|
|
158
|
-
* (not wrapped). `uid` carries the raw iCalendar UID; attendees are display
|
|
159
|
-
* names only.
|
|
160
|
-
*/
|
|
161
|
-
export interface CalendarEventDetail {
|
|
162
|
-
id: string;
|
|
163
|
-
uid: string;
|
|
164
|
-
title: string;
|
|
165
|
-
start: string;
|
|
166
|
-
end: string;
|
|
167
|
-
location?: string;
|
|
168
|
-
description?: string;
|
|
169
|
-
attendees?: string[];
|
|
170
|
-
recurrence?: string;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function toDetail(event: CalDavEvent): CalendarEventDetail {
|
|
174
|
-
const attendees = displayAttendees(event);
|
|
175
|
-
const detail: CalendarEventDetail = {
|
|
176
|
-
id: event.href || event.uid,
|
|
177
|
-
uid: event.uid,
|
|
178
|
-
title: event.summary,
|
|
179
|
-
start: event.start,
|
|
180
|
-
end: event.end,
|
|
181
|
-
};
|
|
182
|
-
if (event.location !== undefined) detail.location = event.location;
|
|
183
|
-
if (event.description !== undefined) detail.description = event.description;
|
|
184
|
-
if (attendees.length > 0) detail.attendees = attendees;
|
|
185
|
-
if (event.recurrence !== undefined) detail.recurrence = event.recurrence;
|
|
186
|
-
return detail;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// ---------------------------------------------------------------------------
|
|
190
|
-
// Handler bodies
|
|
191
|
-
// ---------------------------------------------------------------------------
|
|
192
|
-
|
|
193
|
-
interface ListBody {
|
|
194
|
-
calendarId?: string;
|
|
195
|
-
from?: string;
|
|
196
|
-
to?: string;
|
|
197
|
-
limit?: number;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
interface GetBody {
|
|
201
|
-
eventId: string;
|
|
202
|
-
calendarId?: string;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
interface ExportBody {
|
|
206
|
-
calendarId?: string;
|
|
207
|
-
from?: string;
|
|
208
|
-
to?: string;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Register all five calendar handlers against the SDK catalog held by `ctx`.
|
|
213
|
-
* Returns a single Unregister that detaches them in reverse order.
|
|
214
|
-
*/
|
|
215
|
-
export function registerCalendarMethods(
|
|
216
|
-
ctx: HandlerContext,
|
|
217
|
-
options: RegisterCalendarOptions = {},
|
|
218
|
-
): Unregister {
|
|
219
|
-
const clientFactory = options.clientFactory ?? realClientFactory;
|
|
220
|
-
const getClient = (): Promise<CalDavClient> => clientFactory(ctx);
|
|
221
|
-
|
|
222
|
-
const listHandler: TypedHandler<unknown, { events: CalendarEventSummary[] }> = async ({ body }) => {
|
|
223
|
-
const record = body === undefined || body === null ? {} : asRecord(body);
|
|
224
|
-
const input: ListBody = {
|
|
225
|
-
calendarId: optionalString(record, 'calendarId'),
|
|
226
|
-
from: optionalString(record, 'from'),
|
|
227
|
-
to: optionalString(record, 'to'),
|
|
228
|
-
limit: optionalNumber(record, 'limit'),
|
|
229
|
-
};
|
|
230
|
-
if (input.from) validateIsoDate(input.from, 'from');
|
|
231
|
-
if (input.to) validateIsoDate(input.to, 'to');
|
|
232
|
-
const limit = input.limit !== undefined ? Math.max(1, Math.min(200, Math.floor(input.limit))) : 20;
|
|
233
|
-
const client = await getClient();
|
|
234
|
-
const events = await client.listEvents({ ...input, limit });
|
|
235
|
-
return { events: events.map(toSummary) };
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
const getHandler: TypedHandler<unknown, CalendarEventDetail> = async ({ body }) => {
|
|
239
|
-
const record = asRecord(body);
|
|
240
|
-
const input: GetBody = {
|
|
241
|
-
eventId: requiredString(record, 'eventId'),
|
|
242
|
-
calendarId: optionalString(record, 'calendarId'),
|
|
243
|
-
};
|
|
244
|
-
const client = await getClient();
|
|
245
|
-
const event = await client.getEvent(input.eventId, input.calendarId);
|
|
246
|
-
if (!event) {
|
|
247
|
-
throw new HandlerError(`Event not found: ${input.eventId}`, 'CALENDAR_NOT_FOUND', 404);
|
|
248
|
-
}
|
|
249
|
-
return toDetail(event);
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
const createHandler: TypedHandler<unknown, { eventId: string; uid: string; createdAt: string }> = async ({ body }) => {
|
|
253
|
-
const record = asRecord(body);
|
|
254
|
-
const title = requiredString(record, 'title');
|
|
255
|
-
const start = validateIsoDate(requiredString(record, 'start'), 'start');
|
|
256
|
-
const end = validateIsoDate(requiredString(record, 'end'), 'end');
|
|
257
|
-
if (new Date(end).getTime() < new Date(start).getTime()) {
|
|
258
|
-
throw new HandlerError("Field 'end' must not be before 'start'.", 'CALENDAR_BAD_INPUT', 400);
|
|
259
|
-
}
|
|
260
|
-
const client = await getClient();
|
|
261
|
-
const created = await client.createEvent({
|
|
262
|
-
title,
|
|
263
|
-
start,
|
|
264
|
-
end,
|
|
265
|
-
description: optionalString(record, 'description'),
|
|
266
|
-
attendees: optionalStringArray(record, 'attendees'),
|
|
267
|
-
location: optionalString(record, 'location'),
|
|
268
|
-
calendarId: optionalString(record, 'calendarId'),
|
|
269
|
-
});
|
|
270
|
-
return { eventId: created.eventId, uid: created.uid, createdAt: created.createdAt };
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
const importHandler: TypedHandler<unknown, { imported: number; eventIds: string[]; errors: string[] }> = async ({ body }) => {
|
|
274
|
-
const record = asRecord(body);
|
|
275
|
-
const icsContent = requiredString(record, 'icsContent');
|
|
276
|
-
const calendarId = optionalString(record, 'calendarId');
|
|
277
|
-
const client = await getClient();
|
|
278
|
-
return client.importIcs(icsContent, calendarId);
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const exportHandler: TypedHandler<unknown, { icsContent: string; eventCount: number }> = async ({ body }) => {
|
|
282
|
-
const record = body === undefined || body === null ? {} : asRecord(body);
|
|
283
|
-
const input: ExportBody = {
|
|
284
|
-
calendarId: optionalString(record, 'calendarId'),
|
|
285
|
-
from: optionalString(record, 'from'),
|
|
286
|
-
to: optionalString(record, 'to'),
|
|
287
|
-
};
|
|
288
|
-
if (input.from) validateIsoDate(input.from, 'from');
|
|
289
|
-
if (input.to) validateIsoDate(input.to, 'to');
|
|
290
|
-
const client = await getClient();
|
|
291
|
-
return client.exportIcs(input);
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const entries: CatalogHandlerEntry[] = [
|
|
295
|
-
{ id: 'calendar.events.list', handler: listHandler as TypedHandler<unknown, unknown> },
|
|
296
|
-
{ id: 'calendar.events.get', handler: getHandler as TypedHandler<unknown, unknown> },
|
|
297
|
-
{
|
|
298
|
-
id: 'calendar.events.create',
|
|
299
|
-
handler: createHandler as TypedHandler<unknown, unknown>,
|
|
300
|
-
options: { confirm: true },
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
id: 'calendar.ics.import',
|
|
304
|
-
handler: importHandler as TypedHandler<unknown, unknown>,
|
|
305
|
-
options: { confirm: true },
|
|
306
|
-
},
|
|
307
|
-
{ id: 'calendar.ics.export', handler: exportHandler as TypedHandler<unknown, unknown> },
|
|
308
|
-
];
|
|
309
|
-
|
|
310
|
-
return registerCatalogHandlers(ctx.catalog, entries);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** SurfaceRegister-compatible entry point used by the daemon composition root. */
|
|
314
|
-
export function registerCalendar(ctx: HandlerContext): Unregister {
|
|
315
|
-
return registerCalendarMethods(ctx);
|
|
316
|
-
}
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Email settings resolution + connector seams.
|
|
3
|
-
//
|
|
4
|
-
// Non-secret host/port/user/folder values come from the config manager; the
|
|
5
|
-
// IMAP/SMTP passwords come EXCLUSIVELY from the daemon credential store. No
|
|
6
|
-
// secret value is ever echoed into a response or logged. Throws
|
|
7
|
-
// HandlerError(EMAIL_NOT_CONFIGURED / EMAIL_CREDENTIALS_MISSING, 400) when a
|
|
8
|
-
// required field is missing so callers get a deterministic failure.
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
12
|
-
import type { HandlerContext } from '../context.ts';
|
|
13
|
-
import type { DaemonCredentialStore } from '../credentials.ts';
|
|
14
|
-
import { HandlerError } from '../errors.ts';
|
|
15
|
-
import {
|
|
16
|
-
ImapConnector,
|
|
17
|
-
type ImapConnectionSettings,
|
|
18
|
-
type ImapEnvelopeSummary,
|
|
19
|
-
type ImapFullMessage,
|
|
20
|
-
} from './imap-connector.ts';
|
|
21
|
-
import {
|
|
22
|
-
SmtpConnector,
|
|
23
|
-
type SmtpConnectionSettings,
|
|
24
|
-
type SmtpMessage,
|
|
25
|
-
type SmtpSendResult,
|
|
26
|
-
} from './smtp-connector.ts';
|
|
27
|
-
|
|
28
|
-
export const CONFIG_PREFIX = 'surfaces.email';
|
|
29
|
-
|
|
30
|
-
type ConfigManagerSlice = Pick<ConfigManager, 'get' | 'getCategory'>;
|
|
31
|
-
type ConfigGetKey = Parameters<ConfigManager['get']>[0];
|
|
32
|
-
|
|
33
|
-
function readConfigString(configManager: ConfigManagerSlice, key: string): string | undefined {
|
|
34
|
-
const value = configManager.get(key as ConfigGetKey);
|
|
35
|
-
if (typeof value === 'string' && value.trim().length > 0) return value.trim();
|
|
36
|
-
return undefined;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function readConfigNumber(configManager: ConfigManagerSlice, key: string, fallback: number): number {
|
|
40
|
-
const value = configManager.get(key as ConfigGetKey);
|
|
41
|
-
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
42
|
-
if (typeof value === 'string' && value.trim() !== '') {
|
|
43
|
-
const parsed = Number(value);
|
|
44
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
45
|
-
}
|
|
46
|
-
return fallback;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function readConfigBool(configManager: ConfigManagerSlice, key: string, fallback: boolean): boolean {
|
|
50
|
-
const value = configManager.get(key as ConfigGetKey);
|
|
51
|
-
if (typeof value === 'boolean') return value;
|
|
52
|
-
if (typeof value === 'string') {
|
|
53
|
-
if (/^(true|1|yes)$/i.test(value.trim())) return true;
|
|
54
|
-
if (/^(false|0|no)$/i.test(value.trim())) return false;
|
|
55
|
-
}
|
|
56
|
-
return fallback;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface ResolvedEmailSettings {
|
|
60
|
-
readonly imap: ImapConnectionSettings;
|
|
61
|
-
readonly smtp: SmtpConnectionSettings;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Resolve all email settings. Passwords are read only via the daemon credential
|
|
66
|
-
* store (`resolveConfigSecret`); everything else comes from the config manager.
|
|
67
|
-
*/
|
|
68
|
-
export async function resolveEmailSettings(
|
|
69
|
-
configManager: ConfigManagerSlice,
|
|
70
|
-
credentials: DaemonCredentialStore,
|
|
71
|
-
): Promise<ResolvedEmailSettings> {
|
|
72
|
-
const imapHost = readConfigString(configManager, `${CONFIG_PREFIX}.imap.host`)
|
|
73
|
-
?? readConfigString(configManager, `${CONFIG_PREFIX}.host`);
|
|
74
|
-
const smtpHost = readConfigString(configManager, `${CONFIG_PREFIX}.smtp.host`)
|
|
75
|
-
?? readConfigString(configManager, `${CONFIG_PREFIX}.host`);
|
|
76
|
-
const user = readConfigString(configManager, `${CONFIG_PREFIX}.user`)
|
|
77
|
-
?? readConfigString(configManager, `${CONFIG_PREFIX}.username`);
|
|
78
|
-
const from = readConfigString(configManager, `${CONFIG_PREFIX}.from`) ?? user;
|
|
79
|
-
|
|
80
|
-
// Password: prefer an explicit IMAP/SMTP secret key, fall back to the shared one.
|
|
81
|
-
const password = (await credentials.resolveConfigSecret(`${CONFIG_PREFIX}.password`))
|
|
82
|
-
?? (await credentials.resolveConfigSecret(`${CONFIG_PREFIX}.imap.password`))
|
|
83
|
-
?? '';
|
|
84
|
-
const smtpPassword = (await credentials.resolveConfigSecret(`${CONFIG_PREFIX}.smtp.password`))
|
|
85
|
-
?? password;
|
|
86
|
-
|
|
87
|
-
if (!imapHost || !smtpHost || !user) {
|
|
88
|
-
throw new HandlerError(
|
|
89
|
-
'Email is not configured. Set surfaces.email.host, surfaces.email.user, and the email password secret.',
|
|
90
|
-
'EMAIL_NOT_CONFIGURED',
|
|
91
|
-
400,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
if (!password) {
|
|
95
|
-
throw new HandlerError(
|
|
96
|
-
'Email password secret is missing from the daemon credential store.',
|
|
97
|
-
'EMAIL_CREDENTIALS_MISSING',
|
|
98
|
-
400,
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const imap: ImapConnectionSettings = {
|
|
103
|
-
host: imapHost,
|
|
104
|
-
port: readConfigNumber(configManager, `${CONFIG_PREFIX}.imap.port`, 993),
|
|
105
|
-
user,
|
|
106
|
-
password,
|
|
107
|
-
secure: readConfigBool(configManager, `${CONFIG_PREFIX}.imap.secure`, true),
|
|
108
|
-
mailbox: readConfigString(configManager, `${CONFIG_PREFIX}.imap.mailbox`) ?? 'INBOX',
|
|
109
|
-
draftsMailbox: readConfigString(configManager, `${CONFIG_PREFIX}.imap.draftsMailbox`) ?? 'Drafts',
|
|
110
|
-
};
|
|
111
|
-
const smtp: SmtpConnectionSettings = {
|
|
112
|
-
host: smtpHost,
|
|
113
|
-
port: readConfigNumber(configManager, `${CONFIG_PREFIX}.smtp.port`, 465),
|
|
114
|
-
user,
|
|
115
|
-
password: smtpPassword,
|
|
116
|
-
secure: readConfigBool(configManager, `${CONFIG_PREFIX}.smtp.secure`, true),
|
|
117
|
-
from: from ?? user,
|
|
118
|
-
};
|
|
119
|
-
return { imap, smtp };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
// Connector seams (production defaults + injectable for tests)
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
/** The IMAP surface the email handlers depend on. ImapConnector satisfies this. */
|
|
127
|
-
export interface ImapClient {
|
|
128
|
-
connect(): Promise<void>;
|
|
129
|
-
close(): Promise<void>;
|
|
130
|
-
listMessages(options: { limit: number; since?: string; unreadOnly: boolean }): Promise<ImapEnvelopeSummary[]>;
|
|
131
|
-
readMessage(uid: number): Promise<ImapFullMessage>;
|
|
132
|
-
appendDraft(rawMessage: string): Promise<{ uid: number; mailbox: string }>;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** The SMTP surface the email handlers depend on. SmtpConnector satisfies this. */
|
|
136
|
-
export interface SmtpClient {
|
|
137
|
-
connect(): Promise<void>;
|
|
138
|
-
close(): Promise<void>;
|
|
139
|
-
send(message: SmtpMessage): Promise<SmtpSendResult>;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export type ImapFactory = (settings: ImapConnectionSettings) => Promise<ImapClient>;
|
|
143
|
-
export type SmtpFactory = (settings: SmtpConnectionSettings) => Promise<SmtpClient>;
|
|
144
|
-
|
|
145
|
-
export interface EmailMethodsOptions {
|
|
146
|
-
/** Override the IMAP client factory (used in tests). */
|
|
147
|
-
readonly imapFactory?: ImapFactory;
|
|
148
|
-
/** Override the SMTP client factory (used in tests). */
|
|
149
|
-
readonly smtpFactory?: SmtpFactory;
|
|
150
|
-
/** Override the working directory used for the at-rest draft store. */
|
|
151
|
-
readonly workingDirectory?: string;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export const defaultImapFactory: ImapFactory = async (settings) => {
|
|
155
|
-
const imap = new ImapConnector(settings);
|
|
156
|
-
await imap.connect();
|
|
157
|
-
return imap;
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
export const defaultSmtpFactory: SmtpFactory = async (settings) => {
|
|
161
|
-
const smtp = new SmtpConnector(settings);
|
|
162
|
-
await smtp.connect();
|
|
163
|
-
return smtp;
|
|
164
|
-
};
|