@pellux/goodvibes-tui 0.24.0 → 0.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -5
  3. package/docs/foundation-artifacts/operator-contract.json +304 -230
  4. package/package.json +2 -2
  5. package/src/daemon/calendar/caldav-client.ts +657 -0
  6. package/src/daemon/calendar/ics.ts +556 -0
  7. package/src/daemon/calendar/index.ts +52 -0
  8. package/src/daemon/calendar/register.ts +527 -0
  9. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  10. package/src/daemon/channels/drafts/index.ts +22 -0
  11. package/src/daemon/channels/drafts/register.ts +449 -0
  12. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  13. package/src/daemon/channels/inbox/index.ts +58 -0
  14. package/src/daemon/channels/inbox/mapping.ts +190 -0
  15. package/src/daemon/channels/inbox/poller.ts +155 -0
  16. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  17. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  18. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  19. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  20. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  21. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  22. package/src/daemon/channels/inbox/register.ts +247 -0
  23. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  24. package/src/daemon/channels/routing/index.ts +39 -0
  25. package/src/daemon/channels/routing/register.ts +296 -0
  26. package/src/daemon/channels/routing/route-store.ts +278 -0
  27. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  28. package/src/daemon/email/imap-connector.ts +441 -0
  29. package/src/daemon/email/imap-parsing.ts +499 -0
  30. package/src/daemon/email/index.ts +68 -0
  31. package/src/daemon/email/register.ts +715 -0
  32. package/src/daemon/email/smtp-connector.ts +557 -0
  33. package/src/daemon/operator/credential-store.ts +129 -0
  34. package/src/daemon/operator/index.ts +43 -0
  35. package/src/daemon/operator/register-helper.ts +150 -0
  36. package/src/daemon/operator/sqlite-store.ts +124 -0
  37. package/src/daemon/operator/surfaces.ts +137 -0
  38. package/src/daemon/operator/types.ts +207 -0
  39. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  40. package/src/daemon/remote/backends/docker.ts +80 -0
  41. package/src/daemon/remote/backends/index.ts +34 -0
  42. package/src/daemon/remote/backends/local-process.ts +113 -0
  43. package/src/daemon/remote/backends/process-runner.ts +151 -0
  44. package/src/daemon/remote/backends/ssh.ts +120 -0
  45. package/src/daemon/remote/backends/types.ts +71 -0
  46. package/src/daemon/remote/dispatcher.ts +160 -0
  47. package/src/daemon/remote/index.ts +74 -0
  48. package/src/daemon/remote/peer-registry.ts +321 -0
  49. package/src/daemon/remote/register.ts +411 -0
  50. package/src/daemon/triage/index.ts +59 -0
  51. package/src/daemon/triage/integration.ts +179 -0
  52. package/src/daemon/triage/pipeline.ts +285 -0
  53. package/src/daemon/triage/register.ts +231 -0
  54. package/src/daemon/triage/scorer.ts +287 -0
  55. package/src/daemon/triage/tagger.ts +777 -0
  56. package/src/runtime/services.ts +35 -0
  57. package/src/version.ts +1 -1
@@ -0,0 +1,715 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Email operator-method surface (IMAP / SMTP).
3
+ //
4
+ // Publishes: email.inbox.list, email.inbox.read, email.draft.create, email.send
5
+ //
6
+ // Wiring contract: integration calls registerEmailMethods(ctx) exactly once and
7
+ // retains the returned Unregister to tear the surface down.
8
+ //
9
+ // Credential posture: host/user/password and the SMTP From are resolved ONLY
10
+ // from the daemon credential store / config manager and are NEVER echoed into
11
+ // responses or logs. Sender addresses are reduced to a sha256 digest before
12
+ // logging (PII stripping). Draft bodies are encrypted at rest (AES-256-GCM)
13
+ // before being persisted to the operator SQLite store.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
17
+ import {
18
+ OperatorError,
19
+ createDaemonCredentialStore,
20
+ createAtRestCipher,
21
+ declareOperatorMethods,
22
+ sha256First,
23
+ type AtRestCipher,
24
+ type DaemonCredentialStore,
25
+ type DraftRecord,
26
+ type OperatorContext,
27
+ type Unregister,
28
+ } from '../operator/index.ts';
29
+ import { OperatorSqliteStore } from '../operator/index.ts';
30
+ import {
31
+ ImapConnector,
32
+ type ImapConnectionSettings,
33
+ type ImapEnvelopeSummary,
34
+ type ImapFullMessage,
35
+ } from './imap-connector.ts';
36
+ import {
37
+ SmtpConnector,
38
+ buildRfc5322Message,
39
+ generateMessageId,
40
+ type SmtpConnectionSettings,
41
+ type SmtpMessage,
42
+ type SmtpSendResult,
43
+ } from './smtp-connector.ts';
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Connector seams (production defaults + injectable for tests)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** The IMAP surface the email methods depend on. ImapConnector satisfies this. */
50
+ export interface ImapClient {
51
+ connect(): Promise<void>;
52
+ close(): Promise<void>;
53
+ listMessages(options: { limit: number; since?: string; unreadOnly: boolean }): Promise<ImapEnvelopeSummary[]>;
54
+ readMessage(uid: number): Promise<ImapFullMessage>;
55
+ appendDraft(rawMessage: string): Promise<{ uid: number; mailbox: string }>;
56
+ }
57
+
58
+ /** The SMTP surface the email methods depend on. SmtpConnector satisfies this. */
59
+ export interface SmtpClient {
60
+ connect(): Promise<void>;
61
+ close(): Promise<void>;
62
+ send(message: SmtpMessage): Promise<SmtpSendResult>;
63
+ }
64
+
65
+ export interface EmailMethodsOptions {
66
+ /** Override the IMAP client factory (used in tests). */
67
+ readonly imapFactory?: (settings: ImapConnectionSettings) => Promise<ImapClient>;
68
+ /** Override the SMTP client factory (used in tests). */
69
+ readonly smtpFactory?: (settings: SmtpConnectionSettings) => Promise<SmtpClient>;
70
+ }
71
+
72
+ const defaultImapFactory = async (settings: ImapConnectionSettings): Promise<ImapClient> => {
73
+ const imap = new ImapConnector(settings);
74
+ await imap.connect();
75
+ return imap;
76
+ };
77
+
78
+ const defaultSmtpFactory = async (settings: SmtpConnectionSettings): Promise<SmtpClient> => {
79
+ const smtp = new SmtpConnector(settings);
80
+ await smtp.connect();
81
+ return smtp;
82
+ };
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Config resolution
86
+ // ---------------------------------------------------------------------------
87
+
88
+ const CONFIG_PREFIX = 'surfaces.email';
89
+ const DRAFT_STORE_FILE = 'email-drafts.sqlite';
90
+
91
+ type ConfigGetKey = Parameters<ConfigManager['get']>[0];
92
+
93
+ function readConfigString(
94
+ configManager: OperatorContext['configManager'],
95
+ key: string,
96
+ ): string | undefined {
97
+ const value = configManager.get(key as ConfigGetKey);
98
+ if (typeof value === 'string' && value.trim().length > 0) return value.trim();
99
+ return undefined;
100
+ }
101
+
102
+ function readConfigNumber(
103
+ configManager: OperatorContext['configManager'],
104
+ key: string,
105
+ fallback: number,
106
+ ): number {
107
+ const value = configManager.get(key as ConfigGetKey);
108
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
109
+ if (typeof value === 'string' && value.trim() !== '') {
110
+ const parsed = Number(value);
111
+ if (Number.isFinite(parsed)) return parsed;
112
+ }
113
+ return fallback;
114
+ }
115
+
116
+ function readConfigBool(
117
+ configManager: OperatorContext['configManager'],
118
+ key: string,
119
+ fallback: boolean,
120
+ ): boolean {
121
+ const value = configManager.get(key as ConfigGetKey);
122
+ if (typeof value === 'boolean') return value;
123
+ if (typeof value === 'string') {
124
+ if (/^(true|1|yes)$/i.test(value.trim())) return true;
125
+ if (/^(false|0|no)$/i.test(value.trim())) return false;
126
+ }
127
+ return fallback;
128
+ }
129
+
130
+ export interface ResolvedEmailSettings {
131
+ readonly imap: ImapConnectionSettings;
132
+ readonly smtp: SmtpConnectionSettings;
133
+ }
134
+
135
+ /**
136
+ * Resolve all email settings. Secrets (passwords) come exclusively from the
137
+ * daemon credential store; non-secret host/port/user/folder values come from
138
+ * the config manager. Throws OperatorError(EMAIL_NOT_CONFIGURED) when required
139
+ * fields are missing so callers receive a deterministic 400.
140
+ */
141
+ export async function resolveEmailSettings(
142
+ configManager: OperatorContext['configManager'],
143
+ credentials: DaemonCredentialStore,
144
+ ): Promise<ResolvedEmailSettings> {
145
+ const imapHost = readConfigString(configManager, `${CONFIG_PREFIX}.imap.host`)
146
+ ?? readConfigString(configManager, `${CONFIG_PREFIX}.host`);
147
+ const smtpHost = readConfigString(configManager, `${CONFIG_PREFIX}.smtp.host`)
148
+ ?? readConfigString(configManager, `${CONFIG_PREFIX}.host`);
149
+ const user = readConfigString(configManager, `${CONFIG_PREFIX}.user`)
150
+ ?? readConfigString(configManager, `${CONFIG_PREFIX}.username`);
151
+ const from = readConfigString(configManager, `${CONFIG_PREFIX}.from`) ?? user;
152
+
153
+ // Password: prefer an explicit secret key, fall back to the config-derived key.
154
+ const password = (await credentials.resolveConfigSecret(`${CONFIG_PREFIX}.password`))
155
+ ?? (await credentials.resolveConfigSecret(`${CONFIG_PREFIX}.imap.password`))
156
+ ?? '';
157
+ const smtpPassword = (await credentials.resolveConfigSecret(`${CONFIG_PREFIX}.smtp.password`))
158
+ ?? password;
159
+
160
+ if (!imapHost || !smtpHost || !user) {
161
+ throw new OperatorError(
162
+ 'Email is not configured. Set surfaces.email.host, surfaces.email.user, and the email password secret.',
163
+ 'EMAIL_NOT_CONFIGURED',
164
+ 400,
165
+ );
166
+ }
167
+ if (!password) {
168
+ throw new OperatorError(
169
+ 'Email password secret is missing from the daemon credential store.',
170
+ 'EMAIL_CREDENTIALS_MISSING',
171
+ 400,
172
+ );
173
+ }
174
+
175
+ const imap: ImapConnectionSettings = {
176
+ host: imapHost,
177
+ port: readConfigNumber(configManager, `${CONFIG_PREFIX}.imap.port`, 993),
178
+ user,
179
+ password,
180
+ secure: readConfigBool(configManager, `${CONFIG_PREFIX}.imap.secure`, true),
181
+ mailbox: readConfigString(configManager, `${CONFIG_PREFIX}.imap.mailbox`) ?? 'INBOX',
182
+ draftsMailbox: readConfigString(configManager, `${CONFIG_PREFIX}.imap.draftsMailbox`) ?? 'Drafts',
183
+ };
184
+ const smtp: SmtpConnectionSettings = {
185
+ host: smtpHost,
186
+ port: readConfigNumber(configManager, `${CONFIG_PREFIX}.smtp.port`, 465),
187
+ user,
188
+ password: smtpPassword,
189
+ secure: readConfigBool(configManager, `${CONFIG_PREFIX}.smtp.secure`, true),
190
+ from: from ?? user,
191
+ };
192
+ return { imap, smtp };
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Input validation
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function asRecord(body: unknown): Record<string, unknown> {
200
+ if (typeof body !== 'object' || body === null) {
201
+ throw new OperatorError('Request body must be an object', 'EMAIL_BAD_INPUT', 400);
202
+ }
203
+ return body as Record<string, unknown>;
204
+ }
205
+
206
+ function requireString(value: unknown, field: string): string {
207
+ if (typeof value !== 'string' || value.trim().length === 0) {
208
+ throw new OperatorError(`Field '${field}' is required`, 'EMAIL_BAD_INPUT', 400);
209
+ }
210
+ return value;
211
+ }
212
+
213
+ function optionalString(value: unknown, field: string): string | undefined {
214
+ if (value === undefined || value === null) return undefined;
215
+ if (typeof value !== 'string') {
216
+ throw new OperatorError(`Field '${field}' must be a string`, 'EMAIL_BAD_INPUT', 400);
217
+ }
218
+ return value;
219
+ }
220
+
221
+ function extractAddrSpec(entry: string): string {
222
+ // RFC5322 name-addr form: "Display Name <addr@host>". The angle brackets
223
+ // delimit the addr-spec, so extract the contents of the LAST <...> pair
224
+ // rather than greedily stripping everything around stray '<'/'>' chars
225
+ // (which could appear inside a quoted display name).
226
+ const open = entry.lastIndexOf('<');
227
+ if (open !== -1) {
228
+ const close = entry.indexOf('>', open + 1);
229
+ if (close !== -1) return entry.slice(open + 1, close).trim();
230
+ }
231
+ return entry.trim();
232
+ }
233
+
234
+ function validateEmailAddress(value: string, field: string): string {
235
+ // Accept comma-separated lists; each entry must contain an '@'.
236
+ const entries = value.split(',').map((s) => s.trim()).filter(Boolean);
237
+ if (entries.length === 0 || entries.some((e) => !/.+@.+\..+/.test(extractAddrSpec(e)))) {
238
+ throw new OperatorError(`Field '${field}' must be a valid email address`, 'EMAIL_BAD_INPUT', 400);
239
+ }
240
+ return value;
241
+ }
242
+
243
+ function clampLimit(value: unknown): number {
244
+ if (value === undefined || value === null) return 10;
245
+ const n = typeof value === 'number' ? value : Number(value);
246
+ if (!Number.isFinite(n)) {
247
+ throw new OperatorError("Field 'limit' must be a number", 'EMAIL_BAD_INPUT', 400);
248
+ }
249
+ return Math.min(100, Math.max(1, Math.floor(n)));
250
+ }
251
+
252
+ function validateIsoDate(value: unknown): string | undefined {
253
+ const str = optionalString(value, 'since');
254
+ if (str === undefined) return undefined;
255
+ if (Number.isNaN(new Date(str).getTime())) {
256
+ throw new OperatorError("Field 'since' must be an ISO-8601 date", 'EMAIL_BAD_INPUT', 400);
257
+ }
258
+ return str;
259
+ }
260
+
261
+ function requireUid(value: unknown): number {
262
+ const n = typeof value === 'number' ? value : Number(value);
263
+ if (!Number.isInteger(n) || n <= 0) {
264
+ throw new OperatorError("Field 'uid' must be a positive integer", 'EMAIL_BAD_INPUT', 400);
265
+ }
266
+ return n;
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Logging helpers (PII-safe)
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /** Reduce a sender/recipient address to a stable, non-reversible digest. */
274
+ function addressDigest(address: string): string {
275
+ return sha256First(address.toLowerCase().trim(), 16);
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Output shaping (response contracts)
280
+ // ---------------------------------------------------------------------------
281
+
282
+ interface InboxListResponse {
283
+ messages: Array<{
284
+ uid: number;
285
+ from: string;
286
+ subject: string;
287
+ date: string;
288
+ unread: boolean;
289
+ bodyPreview: string;
290
+ messageId: string;
291
+ }>;
292
+ total: number;
293
+ }
294
+
295
+ interface InboxReadResponse {
296
+ uid: number;
297
+ from: string;
298
+ subject: string;
299
+ date: string;
300
+ messageId: string;
301
+ bodyText: string;
302
+ bodyHtml?: string;
303
+ attachments?: Array<{ filename: string; contentType: string; sizeBytes: number }>;
304
+ }
305
+
306
+ interface DraftCreateResponse {
307
+ uid: number;
308
+ draftId: string;
309
+ }
310
+
311
+ interface SendResponse {
312
+ messageId: string;
313
+ sentAt: string;
314
+ }
315
+
316
+ function toListMessage(m: ImapEnvelopeSummary): InboxListResponse['messages'][number] {
317
+ return {
318
+ uid: m.uid,
319
+ from: m.from,
320
+ subject: m.subject,
321
+ date: m.date,
322
+ unread: m.unread,
323
+ bodyPreview: m.bodyPreview,
324
+ messageId: m.messageId,
325
+ };
326
+ }
327
+
328
+ function toReadMessage(m: ImapFullMessage): InboxReadResponse {
329
+ return {
330
+ uid: m.uid,
331
+ from: m.from,
332
+ subject: m.subject,
333
+ date: m.date,
334
+ messageId: m.messageId,
335
+ bodyText: m.bodyText,
336
+ ...(m.bodyHtml ? { bodyHtml: m.bodyHtml } : {}),
337
+ ...(m.attachments && m.attachments.length > 0 ? { attachments: m.attachments } : {}),
338
+ };
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Draft-at-rest persistence
343
+ // ---------------------------------------------------------------------------
344
+
345
+ const DRAFT_SCHEMA: string[] = [
346
+ `CREATE TABLE IF NOT EXISTS email_drafts (
347
+ id TEXT PRIMARY KEY,
348
+ surface TEXT NOT NULL,
349
+ account_id TEXT,
350
+ conversation_id TEXT,
351
+ recipient TEXT,
352
+ subject TEXT,
353
+ body_ciphertext TEXT NOT NULL,
354
+ status TEXT NOT NULL,
355
+ created_at TEXT NOT NULL,
356
+ updated_at TEXT NOT NULL,
357
+ metadata TEXT
358
+ );`,
359
+ ];
360
+
361
+ async function persistDraftRecord(
362
+ store: OperatorSqliteStore,
363
+ cipher: AtRestCipher,
364
+ record: Omit<DraftRecord, 'bodyCiphertext'> & { plaintextBody: string },
365
+ ): Promise<void> {
366
+ const bodyCiphertext = await cipher.encrypt(record.plaintextBody);
367
+ store.run(
368
+ `INSERT OR REPLACE INTO email_drafts
369
+ (id, surface, account_id, conversation_id, recipient, subject,
370
+ body_ciphertext, status, created_at, updated_at, metadata)
371
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
372
+ [
373
+ record.id,
374
+ record.surface,
375
+ record.accountId ?? null,
376
+ record.conversationId ?? null,
377
+ record.to ?? null,
378
+ record.subject ?? null,
379
+ bodyCiphertext,
380
+ record.status,
381
+ record.createdAt,
382
+ record.updatedAt,
383
+ record.metadata ? JSON.stringify(record.metadata) : null,
384
+ ],
385
+ );
386
+ await store.save();
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Surface registration
391
+ // ---------------------------------------------------------------------------
392
+
393
+ const INBOX_LIST_INPUT_SCHEMA: Record<string, unknown> = {
394
+ type: 'object',
395
+ additionalProperties: false,
396
+ properties: {
397
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 10 },
398
+ since: { type: 'string', format: 'date-time' },
399
+ unreadOnly: { type: 'boolean', default: true },
400
+ },
401
+ };
402
+
403
+ const INBOX_LIST_OUTPUT_SCHEMA: Record<string, unknown> = {
404
+ type: 'object',
405
+ required: ['messages', 'total'],
406
+ properties: {
407
+ messages: {
408
+ type: 'array',
409
+ items: {
410
+ type: 'object',
411
+ required: ['uid', 'from', 'subject', 'date', 'unread', 'bodyPreview', 'messageId'],
412
+ properties: {
413
+ uid: { type: 'number' },
414
+ from: { type: 'string' },
415
+ subject: { type: 'string' },
416
+ date: { type: 'string' },
417
+ unread: { type: 'boolean' },
418
+ bodyPreview: { type: 'string' },
419
+ messageId: { type: 'string' },
420
+ },
421
+ },
422
+ },
423
+ total: { type: 'number' },
424
+ },
425
+ };
426
+
427
+ const INBOX_READ_INPUT_SCHEMA: Record<string, unknown> = {
428
+ type: 'object',
429
+ additionalProperties: false,
430
+ required: ['uid'],
431
+ properties: { uid: { type: 'number', minimum: 1 } },
432
+ };
433
+
434
+ const INBOX_READ_OUTPUT_SCHEMA: Record<string, unknown> = {
435
+ type: 'object',
436
+ required: ['uid', 'from', 'subject', 'date', 'messageId', 'bodyText'],
437
+ properties: {
438
+ uid: { type: 'number' },
439
+ from: { type: 'string' },
440
+ subject: { type: 'string' },
441
+ date: { type: 'string' },
442
+ messageId: { type: 'string' },
443
+ bodyText: { type: 'string' },
444
+ bodyHtml: { type: 'string' },
445
+ attachments: {
446
+ type: 'array',
447
+ items: {
448
+ type: 'object',
449
+ properties: {
450
+ filename: { type: 'string' },
451
+ contentType: { type: 'string' },
452
+ sizeBytes: { type: 'number' },
453
+ },
454
+ },
455
+ },
456
+ },
457
+ };
458
+
459
+ const DRAFT_CREATE_INPUT_SCHEMA: Record<string, unknown> = {
460
+ type: 'object',
461
+ additionalProperties: false,
462
+ required: ['to', 'subject', 'body', 'confirm'],
463
+ properties: {
464
+ to: { type: 'string' },
465
+ subject: { type: 'string' },
466
+ body: { type: 'string' },
467
+ inReplyTo: { type: 'string' },
468
+ references: { type: 'string' },
469
+ confirm: { const: true },
470
+ },
471
+ };
472
+
473
+ const DRAFT_CREATE_OUTPUT_SCHEMA: Record<string, unknown> = {
474
+ type: 'object',
475
+ required: ['uid', 'draftId'],
476
+ properties: {
477
+ uid: { type: 'number' },
478
+ draftId: { type: 'string' },
479
+ },
480
+ };
481
+
482
+ const SEND_INPUT_SCHEMA: Record<string, unknown> = {
483
+ type: 'object',
484
+ additionalProperties: false,
485
+ required: ['to', 'subject', 'body', 'confirm'],
486
+ properties: {
487
+ to: { type: 'string' },
488
+ subject: { type: 'string' },
489
+ body: { type: 'string' },
490
+ inReplyTo: { type: 'string' },
491
+ confirm: { const: true },
492
+ },
493
+ };
494
+
495
+ const SEND_OUTPUT_SCHEMA: Record<string, unknown> = {
496
+ type: 'object',
497
+ required: ['messageId', 'sentAt'],
498
+ properties: {
499
+ messageId: { type: 'string' },
500
+ sentAt: { type: 'string' },
501
+ },
502
+ };
503
+
504
+ /**
505
+ * Register the email operator-method surface against the catalog in ctx.
506
+ * Returns an Unregister that removes every method.
507
+ */
508
+ export function registerEmailMethods(
509
+ ctx: OperatorContext,
510
+ options: EmailMethodsOptions = {},
511
+ ): Unregister {
512
+ const credentials = createDaemonCredentialStore(ctx.secrets);
513
+ const cipher = createAtRestCipher(credentials);
514
+ const imapFactory = options.imapFactory ?? defaultImapFactory;
515
+ const smtpFactory = options.smtpFactory ?? defaultSmtpFactory;
516
+
517
+ // Lazily-initialized draft store (only touched by email.draft.create).
518
+ let draftStorePromise: Promise<OperatorSqliteStore> | null = null;
519
+ const getDraftStore = (): Promise<OperatorSqliteStore> => {
520
+ if (!draftStorePromise) {
521
+ const store = new OperatorSqliteStore({
522
+ workingDirectory: ctx.workingDirectory,
523
+ fileName: DRAFT_STORE_FILE,
524
+ schema: DRAFT_SCHEMA,
525
+ });
526
+ draftStorePromise = store.init().then(() => store);
527
+ }
528
+ return draftStorePromise;
529
+ };
530
+
531
+ const withImap = async <T>(fn: (imap: ImapClient) => Promise<T>): Promise<T> => {
532
+ const { imap: settings } = await resolveEmailSettings(ctx.configManager, credentials);
533
+ const imap = await imapFactory(settings);
534
+ try {
535
+ return await fn(imap);
536
+ } finally {
537
+ await imap.close();
538
+ }
539
+ };
540
+
541
+ const teardown = declareOperatorMethods(ctx, [
542
+ // ---- email.inbox.list --------------------------------------------------
543
+ {
544
+ descriptor: {
545
+ id: 'email.inbox.list',
546
+ title: 'List inbox messages',
547
+ description: 'List recent IMAP inbox messages (read-only; does not mark messages as read).',
548
+ category: 'email',
549
+ source: 'daemon',
550
+ access: 'operator',
551
+ transport: ['ws', 'internal'],
552
+ scopes: ['email:read'],
553
+ effect: 'read-only-network',
554
+ inputSchema: INBOX_LIST_INPUT_SCHEMA,
555
+ outputSchema: INBOX_LIST_OUTPUT_SCHEMA,
556
+ },
557
+ handler: async ({ body }): Promise<InboxListResponse> => {
558
+ const input = asRecord(body ?? {});
559
+ const limit = clampLimit(input.limit);
560
+ const since = validateIsoDate(input.since);
561
+ const unreadOnly = input.unreadOnly === undefined ? true : input.unreadOnly === true;
562
+ const messages = await withImap((imap) =>
563
+ imap.listMessages({ limit, since, unreadOnly }),
564
+ );
565
+ ctx.logger.info('email.inbox.list', {
566
+ count: messages.length,
567
+ unreadOnly,
568
+ senders: messages.map((m) => addressDigest(m.from)),
569
+ });
570
+ return { messages: messages.map(toListMessage), total: messages.length };
571
+ },
572
+ },
573
+ // ---- email.inbox.read --------------------------------------------------
574
+ {
575
+ descriptor: {
576
+ id: 'email.inbox.read',
577
+ title: 'Read inbox message',
578
+ description: 'Fetch a full IMAP message body by UID using BODY.PEEK (does not mark as read).',
579
+ category: 'email',
580
+ source: 'daemon',
581
+ access: 'operator',
582
+ transport: ['ws', 'internal'],
583
+ scopes: ['email:read'],
584
+ effect: 'read-only-network',
585
+ inputSchema: INBOX_READ_INPUT_SCHEMA,
586
+ outputSchema: INBOX_READ_OUTPUT_SCHEMA,
587
+ },
588
+ handler: async ({ body }): Promise<InboxReadResponse> => {
589
+ const input = asRecord(body);
590
+ const uid = requireUid(input.uid);
591
+ const message = await withImap((imap) => imap.readMessage(uid));
592
+ ctx.logger.info('email.inbox.read', {
593
+ uid,
594
+ from: addressDigest(message.from),
595
+ hasHtml: Boolean(message.bodyHtml),
596
+ attachments: message.attachments?.length ?? 0,
597
+ });
598
+ return toReadMessage(message);
599
+ },
600
+ },
601
+ // ---- email.draft.create ------------------------------------------------
602
+ {
603
+ descriptor: {
604
+ id: 'email.draft.create',
605
+ title: 'Create email draft',
606
+ description: 'Append a draft message to the IMAP Drafts mailbox. Requires confirmation.',
607
+ category: 'email',
608
+ source: 'daemon',
609
+ access: 'operator',
610
+ transport: ['ws', 'internal'],
611
+ scopes: ['email:write'],
612
+ effect: 'confirmed-effect',
613
+ confirm: true,
614
+ inputSchema: DRAFT_CREATE_INPUT_SCHEMA,
615
+ outputSchema: DRAFT_CREATE_OUTPUT_SCHEMA,
616
+ },
617
+ handler: async ({ body }): Promise<DraftCreateResponse> => {
618
+ const input = asRecord(body);
619
+ const to = validateEmailAddress(requireString(input.to, 'to'), 'to');
620
+ const subject = requireString(input.subject, 'subject');
621
+ const draftBody = requireString(input.body, 'body');
622
+ const inReplyTo = optionalString(input.inReplyTo, 'inReplyTo');
623
+ const references = optionalString(input.references, 'references');
624
+
625
+ const { smtp } = await resolveEmailSettings(ctx.configManager, credentials);
626
+ const messageId = generateMessageId(smtp.from);
627
+ const raw = buildRfc5322Message({
628
+ from: smtp.from,
629
+ to,
630
+ subject,
631
+ body: draftBody,
632
+ messageId,
633
+ date: new Date(),
634
+ ...(inReplyTo ? { inReplyTo } : {}),
635
+ ...(references ? { references } : {}),
636
+ });
637
+ const appended = await withImap((imap) => imap.appendDraft(raw));
638
+
639
+ const now = new Date().toISOString();
640
+ const draftId = messageId.replace(/[<>]/g, '');
641
+ const store = await getDraftStore();
642
+ await persistDraftRecord(store, cipher, {
643
+ id: draftId,
644
+ surface: 'email',
645
+ to,
646
+ subject,
647
+ plaintextBody: draftBody,
648
+ status: 'draft',
649
+ createdAt: now,
650
+ updatedAt: now,
651
+ metadata: { uid: appended.uid, mailbox: appended.mailbox, messageId },
652
+ });
653
+
654
+ ctx.logger.info('email.draft.create', {
655
+ uid: appended.uid,
656
+ draftId,
657
+ recipient: addressDigest(to),
658
+ });
659
+ return { uid: appended.uid, draftId };
660
+ },
661
+ },
662
+ // ---- email.send --------------------------------------------------------
663
+ {
664
+ descriptor: {
665
+ id: 'email.send',
666
+ title: 'Send email',
667
+ description: 'Send an email via SMTP. Irreversible external effect — requires confirmation.',
668
+ category: 'email',
669
+ source: 'daemon',
670
+ access: 'operator',
671
+ transport: ['ws', 'internal'],
672
+ scopes: ['email:send'],
673
+ effect: 'confirmed-effect',
674
+ confirm: true,
675
+ inputSchema: SEND_INPUT_SCHEMA,
676
+ outputSchema: SEND_OUTPUT_SCHEMA,
677
+ },
678
+ handler: async ({ body }): Promise<SendResponse> => {
679
+ const input = asRecord(body);
680
+ const to = validateEmailAddress(requireString(input.to, 'to'), 'to');
681
+ const subject = requireString(input.subject, 'subject');
682
+ const sendBody = requireString(input.body, 'body');
683
+ const inReplyTo = optionalString(input.inReplyTo, 'inReplyTo');
684
+
685
+ const { smtp: settings } = await resolveEmailSettings(ctx.configManager, credentials);
686
+ const smtp = await smtpFactory(settings);
687
+ let result: SendResponse;
688
+ try {
689
+ result = await smtp.send({
690
+ to,
691
+ subject,
692
+ body: sendBody,
693
+ ...(inReplyTo ? { inReplyTo } : {}),
694
+ });
695
+ } finally {
696
+ await smtp.close();
697
+ }
698
+ ctx.logger.info('email.send', {
699
+ messageId: result.messageId,
700
+ sentAt: result.sentAt,
701
+ recipient: addressDigest(to),
702
+ });
703
+ return result;
704
+ },
705
+ },
706
+ ]);
707
+
708
+ return () => {
709
+ teardown();
710
+ if (draftStorePromise) {
711
+ void draftStorePromise.then((store) => store.close()).catch(() => undefined);
712
+ draftStorePromise = null;
713
+ }
714
+ };
715
+ }