@pellux/goodvibes-tui 0.24.1 → 0.26.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 +8 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
- package/src/daemon/handlers/calendar/ics.ts +556 -0
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/handlers/credentials.ts +129 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/email/config.ts +164 -0
- package/src/daemon/handlers/email/imap-connector.ts +441 -0
- package/src/daemon/handlers/email/imap-parsing.ts +499 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/smtp-connector.ts +557 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +155 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
- package/src/daemon/handlers/inbox/providers/email.ts +151 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +124 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/handlers/triage/pipeline.ts +273 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/runtime/services.ts +48 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels.drafts.* handler surface (Channel Drafts / Draft Sync Backend).
|
|
3
|
+
*
|
|
4
|
+
* Attaches host handlers to the four SDK-registered builtin descriptors:
|
|
5
|
+
* channels.drafts.list read:channels read-only
|
|
6
|
+
* channels.drafts.get read:channels read-only
|
|
7
|
+
* channels.drafts.save write:channels access:admin, confirm-gated
|
|
8
|
+
* channels.drafts.delete write:channels access:admin, dangerous, confirm-gated
|
|
9
|
+
*
|
|
10
|
+
* The SDK owns every id, descriptor, access flag, and I/O schema (auto-
|
|
11
|
+
* registered with handler:undefined). This module NEVER re-declares any of
|
|
12
|
+
* them — it looks each up via catalog.get(id) (inside registerCatalogHandler)
|
|
13
|
+
* and re-registers with the wrapped handler. save/delete are confirm-gated via
|
|
14
|
+
* RegisterHandlerOptions.confirm; the wrapper enforces body.confirm === true
|
|
15
|
+
* AND context.explicitUserRequest === true before the handler runs.
|
|
16
|
+
*
|
|
17
|
+
* SECURITY: the message body is encrypted at rest and NEVER returned (the wire
|
|
18
|
+
* `message` field carries the sha256First(body,12) digest). Webhooks are
|
|
19
|
+
* encrypted at rest and ALWAYS redacted on read; RAW webhook tokens submitted
|
|
20
|
+
* to save are REJECTED — the agent must redact ('[redacted]') or pass a
|
|
21
|
+
* goodvibes://secrets/ reference before transmission.
|
|
22
|
+
*/
|
|
23
|
+
import type { HandlerContext } from '../context.ts';
|
|
24
|
+
import { createAtRestCipher } from '../credentials.ts';
|
|
25
|
+
import { HandlerError } from '../errors.ts';
|
|
26
|
+
import { registerCatalogHandlers } from '../register.ts';
|
|
27
|
+
import type { TypedHandler, Unregister } from '../register.ts';
|
|
28
|
+
import {
|
|
29
|
+
ALL_DRAFT_STATUSES,
|
|
30
|
+
DraftSyncStore,
|
|
31
|
+
MAX_DRAFT_LIST_LIMIT,
|
|
32
|
+
WRITABLE_DRAFT_STATUSES,
|
|
33
|
+
} from './draft-store.ts';
|
|
34
|
+
import type {
|
|
35
|
+
DraftListQuery,
|
|
36
|
+
DraftRecord,
|
|
37
|
+
DraftSaveInput,
|
|
38
|
+
DraftStatus,
|
|
39
|
+
} from './draft-store.ts';
|
|
40
|
+
|
|
41
|
+
const BAD_INPUT = 'INVALID_INPUT';
|
|
42
|
+
|
|
43
|
+
const LIST_ID = 'channels.drafts.list';
|
|
44
|
+
const GET_ID = 'channels.drafts.get';
|
|
45
|
+
const SAVE_ID = 'channels.drafts.save';
|
|
46
|
+
const DELETE_ID = 'channels.drafts.delete';
|
|
47
|
+
|
|
48
|
+
const writableStatusSet = new Set<string>(WRITABLE_DRAFT_STATUSES);
|
|
49
|
+
const allStatusSet = new Set<string>(ALL_DRAFT_STATUSES);
|
|
50
|
+
|
|
51
|
+
// --- Input validation helpers ----------------------------------------------
|
|
52
|
+
|
|
53
|
+
function asRecord(body: unknown): Record<string, unknown> {
|
|
54
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
55
|
+
throw new HandlerError('Request body must be an object.', BAD_INPUT, 400);
|
|
56
|
+
}
|
|
57
|
+
return body as Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function optionalString(value: unknown, field: string): string | undefined {
|
|
61
|
+
if (value === undefined || value === null) return undefined;
|
|
62
|
+
if (typeof value !== 'string') {
|
|
63
|
+
throw new HandlerError(`Field '${field}' must be a string.`, BAD_INPUT, 400);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function requiredString(value: unknown, field: string): string {
|
|
69
|
+
const str = optionalString(value, field);
|
|
70
|
+
if (str === undefined || str.length === 0) {
|
|
71
|
+
throw new HandlerError(`Field '${field}' is required.`, BAD_INPUT, 400);
|
|
72
|
+
}
|
|
73
|
+
return str;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function optionalTags(value: unknown): string[] | undefined {
|
|
77
|
+
if (value === undefined || value === null) return undefined;
|
|
78
|
+
if (!Array.isArray(value)) {
|
|
79
|
+
throw new HandlerError("Field 'tags' must be an array of strings.", BAD_INPUT, 400);
|
|
80
|
+
}
|
|
81
|
+
const tags: string[] = [];
|
|
82
|
+
for (const entry of value) {
|
|
83
|
+
if (typeof entry !== 'string') {
|
|
84
|
+
throw new HandlerError("Field 'tags' must contain only strings.", BAD_INPUT, 400);
|
|
85
|
+
}
|
|
86
|
+
tags.push(entry);
|
|
87
|
+
}
|
|
88
|
+
return tags;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function optionalLimit(value: unknown): number | undefined {
|
|
92
|
+
if (value === undefined || value === null) return undefined;
|
|
93
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
94
|
+
throw new HandlerError("Field 'limit' must be an integer.", BAD_INPUT, 400);
|
|
95
|
+
}
|
|
96
|
+
if (value < 1 || value > MAX_DRAFT_LIST_LIMIT) {
|
|
97
|
+
throw new HandlerError(
|
|
98
|
+
`Field 'limit' must be between 1 and ${MAX_DRAFT_LIST_LIMIT}.`,
|
|
99
|
+
BAD_INPUT,
|
|
100
|
+
400,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function optionalVersion(value: unknown): number | undefined {
|
|
107
|
+
if (value === undefined || value === null) return undefined;
|
|
108
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
109
|
+
throw new HandlerError("Field 'version' must be a positive integer.", BAD_INPUT, 400);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Strict ISO-8601 date-time. Anchored so loosely-formatted but Date.parse-able
|
|
115
|
+
// values (e.g. '2020', 'Jan 1 2020', '2020-1-1') are rejected.
|
|
116
|
+
const ISO_8601_DATE_TIME =
|
|
117
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
118
|
+
|
|
119
|
+
function optionalIso8601(value: unknown, field: string): string | undefined {
|
|
120
|
+
const str = optionalString(value, field);
|
|
121
|
+
if (str === undefined) return undefined;
|
|
122
|
+
const time = ISO_8601_DATE_TIME.test(str) ? Date.parse(str) : Number.NaN;
|
|
123
|
+
if (Number.isNaN(time)) {
|
|
124
|
+
throw new HandlerError(`Field '${field}' must be an ISO-8601 timestamp.`, BAD_INPUT, 400);
|
|
125
|
+
}
|
|
126
|
+
return new Date(time).toISOString();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reject RAW webhook tokens submitted to save. The contract requires webhooks
|
|
131
|
+
* to be redacted before transmission; the daemon only stores '[redacted]' or a
|
|
132
|
+
* goodvibes://secrets/ reference (which carries no token material). Any other
|
|
133
|
+
* value bearing a scheme (e.g. an https:// URL with an embedded token) is a raw
|
|
134
|
+
* secret and is rejected outright — never persisted, never logged.
|
|
135
|
+
*/
|
|
136
|
+
function validateWebhook(value: string | undefined): string | undefined {
|
|
137
|
+
if (value === undefined) return undefined;
|
|
138
|
+
if (value === '' || value === '[redacted]') return value;
|
|
139
|
+
if (value.startsWith('goodvibes://secrets/')) return value;
|
|
140
|
+
if (value.includes('://')) {
|
|
141
|
+
throw new HandlerError(
|
|
142
|
+
"Field 'webhook' must be redacted before transmission: pass '[redacted]' "
|
|
143
|
+
+ 'or a goodvibes://secrets/ reference, never a raw webhook URL.',
|
|
144
|
+
'WEBHOOK_REDACTION_REQUIRED',
|
|
145
|
+
400,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseListStatus(value: unknown): DraftStatus | undefined {
|
|
152
|
+
const str = optionalString(value, 'status');
|
|
153
|
+
if (str === undefined) return undefined;
|
|
154
|
+
if (!allStatusSet.has(str)) {
|
|
155
|
+
throw new HandlerError(
|
|
156
|
+
`Field 'status' must be one of: ${ALL_DRAFT_STATUSES.join(', ')}.`,
|
|
157
|
+
BAD_INPUT,
|
|
158
|
+
400,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return str as DraftStatus;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseSaveStatus(value: unknown): DraftStatus | undefined {
|
|
165
|
+
const str = optionalString(value, 'status');
|
|
166
|
+
if (str === undefined) return undefined;
|
|
167
|
+
if (!writableStatusSet.has(str)) {
|
|
168
|
+
throw new HandlerError(
|
|
169
|
+
`Field 'status' must be one of: ${WRITABLE_DRAFT_STATUSES.join(', ')}.`,
|
|
170
|
+
BAD_INPUT,
|
|
171
|
+
400,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return str as DraftStatus;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseListQuery(body: unknown): DraftListQuery {
|
|
178
|
+
const record = asRecord(body ?? {});
|
|
179
|
+
const query: DraftListQuery = {};
|
|
180
|
+
const status = parseListStatus(record.status);
|
|
181
|
+
if (status !== undefined) query.status = status;
|
|
182
|
+
const limit = optionalLimit(record.limit);
|
|
183
|
+
if (limit !== undefined) query.limit = limit;
|
|
184
|
+
return query;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseSaveInput(body: unknown): DraftSaveInput {
|
|
188
|
+
const record = asRecord(body);
|
|
189
|
+
const input: DraftSaveInput = {
|
|
190
|
+
message: requiredString(record.message, 'message'),
|
|
191
|
+
};
|
|
192
|
+
const id = optionalString(record.id, 'id');
|
|
193
|
+
if (id !== undefined) input.id = id;
|
|
194
|
+
const version = optionalVersion(record.version);
|
|
195
|
+
if (version !== undefined) input.version = version;
|
|
196
|
+
const title = optionalString(record.title, 'title');
|
|
197
|
+
if (title !== undefined) input.title = title;
|
|
198
|
+
const channel = optionalString(record.channel, 'channel');
|
|
199
|
+
if (channel !== undefined) input.channel = channel;
|
|
200
|
+
const route = optionalString(record.route, 'route');
|
|
201
|
+
if (route !== undefined) input.route = route;
|
|
202
|
+
const webhook = validateWebhook(optionalString(record.webhook, 'webhook'));
|
|
203
|
+
if (webhook !== undefined) input.webhook = webhook;
|
|
204
|
+
const link = optionalString(record.link, 'link');
|
|
205
|
+
if (link !== undefined) input.link = link;
|
|
206
|
+
const tags = optionalTags(record.tags);
|
|
207
|
+
if (tags !== undefined) input.tags = tags;
|
|
208
|
+
const status = parseSaveStatus(record.status);
|
|
209
|
+
if (status !== undefined) input.status = status;
|
|
210
|
+
const createdAt = optionalIso8601(record.createdAt, 'createdAt');
|
|
211
|
+
if (createdAt !== undefined) input.createdAt = createdAt;
|
|
212
|
+
const updatedAt = optionalIso8601(record.updatedAt, 'updatedAt');
|
|
213
|
+
if (updatedAt !== undefined) input.updatedAt = updatedAt;
|
|
214
|
+
return input;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Both get and delete identify a draft by `draftId` (per the SDK input schema). */
|
|
218
|
+
function parseDraftId(body: unknown): string {
|
|
219
|
+
const record = asRecord(body);
|
|
220
|
+
return requiredString(record.draftId, 'draftId');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- Response shapes (match the SDK output schemas) -------------------------
|
|
224
|
+
|
|
225
|
+
interface ListResponse {
|
|
226
|
+
drafts: DraftRecord[];
|
|
227
|
+
total: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** get: a flat draft (CHANNEL_DRAFT_GET_OUTPUT permits additionalProperties) or a notFound marker. */
|
|
231
|
+
type GetResponse = (DraftRecord & { messageDigest: string }) | { notFound: true; id: string };
|
|
232
|
+
|
|
233
|
+
interface SaveResponse {
|
|
234
|
+
draft: DraftRecord;
|
|
235
|
+
created: boolean;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface DeleteResponse {
|
|
239
|
+
deleted: boolean;
|
|
240
|
+
draftId: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface RegisterDraftsOptions {
|
|
244
|
+
/** Override the sqlite filename (tests). */
|
|
245
|
+
fileName?: string;
|
|
246
|
+
/** Inject a pre-built store (tests). When provided, the caller owns its lifecycle. */
|
|
247
|
+
store?: DraftSyncStore;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Register the channels.drafts.* handlers against the catalog held by `ctx`.
|
|
252
|
+
* Returns an Unregister that removes all four handlers (and closes the store
|
|
253
|
+
* when this function owns it).
|
|
254
|
+
*
|
|
255
|
+
* The store is created eagerly but initialized lazily on first invocation (the
|
|
256
|
+
* SQLite WASM load is deferred until a draft method is actually called). Every
|
|
257
|
+
* mutating handler persists the store via save() after the mutation so the
|
|
258
|
+
* mirror survives daemon restarts.
|
|
259
|
+
*/
|
|
260
|
+
export function registerDraftMethods(
|
|
261
|
+
ctx: HandlerContext,
|
|
262
|
+
options: RegisterDraftsOptions = {},
|
|
263
|
+
): Unregister {
|
|
264
|
+
const store =
|
|
265
|
+
options.store
|
|
266
|
+
?? new DraftSyncStore({
|
|
267
|
+
workingDirectory: ctx.workingDirectory,
|
|
268
|
+
cipher: createAtRestCipher(ctx.credentials),
|
|
269
|
+
...(options.fileName !== undefined ? { fileName: options.fileName } : {}),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
let initPromise: Promise<void> | null = null;
|
|
273
|
+
const ensureInit = (): Promise<void> => {
|
|
274
|
+
if (!initPromise) initPromise = store.init();
|
|
275
|
+
return initPromise;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const listHandler: TypedHandler<unknown, ListResponse> = async ({ body }) => {
|
|
279
|
+
await ensureInit();
|
|
280
|
+
const query = parseListQuery(body);
|
|
281
|
+
const drafts = store.list(query);
|
|
282
|
+
return { drafts, total: store.count(query.status) };
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const getHandler: TypedHandler<unknown, GetResponse> = async ({ body }) => {
|
|
286
|
+
await ensureInit();
|
|
287
|
+
const id = parseDraftId(body);
|
|
288
|
+
const record = store.get(id);
|
|
289
|
+
if (record === null) return { notFound: true, id };
|
|
290
|
+
// get output permits additional properties — expose messageDigest explicitly
|
|
291
|
+
// alongside the schema-required `message` field (both carry the digest).
|
|
292
|
+
return { ...record, messageDigest: record.message };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const saveHandler: TypedHandler<unknown, SaveResponse> = async ({ body }) => {
|
|
296
|
+
await ensureInit();
|
|
297
|
+
const input = parseSaveInput(body);
|
|
298
|
+
const result = await store.upsert(input);
|
|
299
|
+
await store.save();
|
|
300
|
+
return result;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const deleteHandler: TypedHandler<unknown, DeleteResponse> = async ({ body }) => {
|
|
304
|
+
await ensureInit();
|
|
305
|
+
const draftId = parseDraftId(body);
|
|
306
|
+
const deleted = store.delete(draftId);
|
|
307
|
+
if (deleted) await store.save();
|
|
308
|
+
return { deleted, draftId };
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const unregister = registerCatalogHandlers(ctx.catalog, [
|
|
312
|
+
{ id: LIST_ID, handler: listHandler as TypedHandler<unknown, unknown> },
|
|
313
|
+
{ id: GET_ID, handler: getHandler as TypedHandler<unknown, unknown> },
|
|
314
|
+
{
|
|
315
|
+
id: SAVE_ID,
|
|
316
|
+
handler: saveHandler as TypedHandler<unknown, unknown>,
|
|
317
|
+
options: { confirm: true },
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: DELETE_ID,
|
|
321
|
+
handler: deleteHandler as TypedHandler<unknown, unknown>,
|
|
322
|
+
options: { confirm: true },
|
|
323
|
+
},
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
const ownsStore = options.store === undefined;
|
|
327
|
+
return () => {
|
|
328
|
+
unregister();
|
|
329
|
+
if (ownsStore) store.close();
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
};
|