@pellux/goodvibes-tui 0.25.0 → 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 +5 -3
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +28 -24
- 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/{channels → handlers}/drafts/draft-store.ts +114 -50
- 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/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/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/{channels → handlers}/inbox/cursor-store.ts +58 -66
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
- package/src/daemon/{channels → handlers}/inbox/poller.ts +4 -4
- package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
- package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +8 -10
- package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
- package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +2 -2
- package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +9 -11
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
- package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
- package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
- package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
- package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/{triage → handlers/triage}/pipeline.ts +58 -70
- package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
- 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 -35
- package/src/version.ts +1 -1
- package/src/daemon/calendar/index.ts +0 -52
- package/src/daemon/calendar/register.ts +0 -527
- package/src/daemon/channels/drafts/index.ts +0 -22
- package/src/daemon/channels/drafts/register.ts +0 -449
- package/src/daemon/channels/inbox/index.ts +0 -58
- package/src/daemon/channels/inbox/register.ts +0 -247
- package/src/daemon/channels/routing/index.ts +0 -39
- package/src/daemon/channels/routing/register.ts +0 -296
- package/src/daemon/email/index.ts +0 -68
- package/src/daemon/email/register.ts +0 -715
- package/src/daemon/operator/index.ts +0 -43
- package/src/daemon/operator/register-helper.ts +0 -150
- package/src/daemon/operator/surfaces.ts +0 -137
- package/src/daemon/operator/types.ts +0 -207
- package/src/daemon/remote/backends/index.ts +0 -34
- package/src/daemon/remote/index.ts +0 -74
- package/src/daemon/remote/register.ts +0 -411
- package/src/daemon/triage/index.ts +0 -59
- package/src/daemon/triage/integration.ts +0 -179
- package/src/daemon/triage/register.ts +0 -231
- package/src/daemon/triage/tagger.ts +0 -777
- /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
- /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
- /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
- /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
- /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
- /package/src/daemon/{channels → handlers}/inbox/providers/email.ts +0 -0
- /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared email runtime: credential-backed settings resolution, the IMAP/SMTP
|
|
3
|
+
// connector lifecycle helpers, and the encrypt-at-rest draft store. Built once
|
|
4
|
+
// by index.ts and threaded into the read/write handler builders so the surface
|
|
5
|
+
// has a single owner for its stateful resources (lazy store, AES key).
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import type { HandlerContext, HandlerLogger } from '../context.ts';
|
|
9
|
+
import type { DaemonCredentialStore, AtRestCipher } from '../credentials.ts';
|
|
10
|
+
import { createAtRestCipher } from '../credentials.ts';
|
|
11
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
12
|
+
import {
|
|
13
|
+
resolveEmailSettings,
|
|
14
|
+
defaultImapFactory,
|
|
15
|
+
defaultSmtpFactory,
|
|
16
|
+
type EmailMethodsOptions,
|
|
17
|
+
type ImapClient,
|
|
18
|
+
type ImapFactory,
|
|
19
|
+
type SmtpClient,
|
|
20
|
+
type SmtpFactory,
|
|
21
|
+
} from './config.ts';
|
|
22
|
+
|
|
23
|
+
const DRAFT_STORE_FILE = 'email-drafts.sqlite';
|
|
24
|
+
|
|
25
|
+
const DRAFT_SCHEMA: string[] = [
|
|
26
|
+
`CREATE TABLE IF NOT EXISTS email_drafts (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
surface TEXT NOT NULL,
|
|
29
|
+
recipient TEXT,
|
|
30
|
+
subject TEXT,
|
|
31
|
+
body_ciphertext TEXT NOT NULL,
|
|
32
|
+
status TEXT NOT NULL,
|
|
33
|
+
created_at TEXT NOT NULL,
|
|
34
|
+
updated_at TEXT NOT NULL,
|
|
35
|
+
metadata TEXT
|
|
36
|
+
);`,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** A persisted draft. The plaintext body is NEVER stored — only its ciphertext. */
|
|
40
|
+
export interface DraftPersistInput {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly to: string;
|
|
43
|
+
readonly subject: string;
|
|
44
|
+
readonly plaintextBody: string;
|
|
45
|
+
readonly status: string;
|
|
46
|
+
readonly createdAt: string;
|
|
47
|
+
readonly updatedAt: string;
|
|
48
|
+
readonly metadata?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Owner of the email surface's stateful resources. Read and write handler
|
|
53
|
+
* builders receive this instead of reaching into the catalog/context directly.
|
|
54
|
+
*/
|
|
55
|
+
export interface EmailRuntime {
|
|
56
|
+
readonly logger: HandlerLogger;
|
|
57
|
+
/** Run `fn` with a connected IMAP client; always closes it afterward. */
|
|
58
|
+
withImap<T>(fn: (imap: ImapClient) => Promise<T>): Promise<T>;
|
|
59
|
+
/** Open (or reuse) a connected SMTP client. Caller closes it. */
|
|
60
|
+
openSmtp(): Promise<SmtpClient>;
|
|
61
|
+
/** Resolve the SMTP From / Message-ID domain context without exposing secrets. */
|
|
62
|
+
smtpFrom(): Promise<string>;
|
|
63
|
+
/** Encrypt + persist a draft record (body stored only as ciphertext). */
|
|
64
|
+
persistDraft(input: DraftPersistInput): Promise<void>;
|
|
65
|
+
/** Tear down lazily-created resources (draft store handle). */
|
|
66
|
+
dispose(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createEmailRuntime(
|
|
70
|
+
ctx: HandlerContext,
|
|
71
|
+
options: EmailMethodsOptions = {},
|
|
72
|
+
): EmailRuntime {
|
|
73
|
+
const credentials: DaemonCredentialStore = ctx.credentials;
|
|
74
|
+
const cipher: AtRestCipher = createAtRestCipher(credentials);
|
|
75
|
+
const imapFactory: ImapFactory = options.imapFactory ?? defaultImapFactory;
|
|
76
|
+
const smtpFactory: SmtpFactory = options.smtpFactory ?? defaultSmtpFactory;
|
|
77
|
+
const workingDirectory = options.workingDirectory ?? ctx.workingDirectory;
|
|
78
|
+
|
|
79
|
+
let draftStorePromise: Promise<HandlerSqliteStore> | null = null;
|
|
80
|
+
const getDraftStore = (): Promise<HandlerSqliteStore> => {
|
|
81
|
+
if (!draftStorePromise) {
|
|
82
|
+
const store = new HandlerSqliteStore({
|
|
83
|
+
workingDirectory,
|
|
84
|
+
fileName: DRAFT_STORE_FILE,
|
|
85
|
+
schema: DRAFT_SCHEMA,
|
|
86
|
+
});
|
|
87
|
+
draftStorePromise = store.init().then(() => store);
|
|
88
|
+
}
|
|
89
|
+
return draftStorePromise;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
logger: ctx.logger,
|
|
94
|
+
async withImap<T>(fn: (imap: ImapClient) => Promise<T>): Promise<T> {
|
|
95
|
+
const { imap: settings } = await resolveEmailSettings(ctx.configManager, credentials);
|
|
96
|
+
const imap = await imapFactory(settings);
|
|
97
|
+
try {
|
|
98
|
+
return await fn(imap);
|
|
99
|
+
} finally {
|
|
100
|
+
await imap.close();
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async openSmtp(): Promise<SmtpClient> {
|
|
104
|
+
const { smtp: settings } = await resolveEmailSettings(ctx.configManager, credentials);
|
|
105
|
+
return smtpFactory(settings);
|
|
106
|
+
},
|
|
107
|
+
async smtpFrom(): Promise<string> {
|
|
108
|
+
const { smtp } = await resolveEmailSettings(ctx.configManager, credentials);
|
|
109
|
+
return smtp.from;
|
|
110
|
+
},
|
|
111
|
+
async persistDraft(input: DraftPersistInput): Promise<void> {
|
|
112
|
+
const bodyCiphertext = await cipher.encrypt(input.plaintextBody);
|
|
113
|
+
const store = await getDraftStore();
|
|
114
|
+
store.run(
|
|
115
|
+
`INSERT OR REPLACE INTO email_drafts
|
|
116
|
+
(id, surface, recipient, subject, body_ciphertext, status,
|
|
117
|
+
created_at, updated_at, metadata)
|
|
118
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
119
|
+
[
|
|
120
|
+
input.id,
|
|
121
|
+
'email',
|
|
122
|
+
input.to,
|
|
123
|
+
input.subject,
|
|
124
|
+
bodyCiphertext,
|
|
125
|
+
input.status,
|
|
126
|
+
input.createdAt,
|
|
127
|
+
input.updatedAt,
|
|
128
|
+
input.metadata ? JSON.stringify(input.metadata) : null,
|
|
129
|
+
],
|
|
130
|
+
);
|
|
131
|
+
await store.save();
|
|
132
|
+
},
|
|
133
|
+
dispose(): void {
|
|
134
|
+
if (draftStorePromise) {
|
|
135
|
+
void draftStorePromise.then((store) => store.close()).catch(() => undefined);
|
|
136
|
+
draftStorePromise = null;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Input validation, PII-safe digesting, and response shaping for the email
|
|
3
|
+
// handler surface. Pure functions only (no I/O, no secrets).
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { HandlerError } from '../errors.ts';
|
|
8
|
+
import type { ImapEnvelopeSummary, ImapFullMessage } from './imap-connector.ts';
|
|
9
|
+
|
|
10
|
+
export function asRecord(body: unknown): Record<string, unknown> {
|
|
11
|
+
if (typeof body !== 'object' || body === null) {
|
|
12
|
+
throw new HandlerError('Request body must be an object', 'EMAIL_BAD_INPUT', 400);
|
|
13
|
+
}
|
|
14
|
+
return body as Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function requireString(value: unknown, field: string): string {
|
|
18
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
19
|
+
throw new HandlerError(`Field '${field}' is required`, 'EMAIL_BAD_INPUT', 400);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function optionalString(value: unknown, field: string): string | undefined {
|
|
25
|
+
if (value === undefined || value === null) return undefined;
|
|
26
|
+
if (typeof value !== 'string') {
|
|
27
|
+
throw new HandlerError(`Field '${field}' must be a string`, 'EMAIL_BAD_INPUT', 400);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract the addr-spec from an RFC 5322 name-addr ("Display <a@b>"). Reads the
|
|
34
|
+
* contents of the LAST <...> pair so a stray '<' inside a quoted display name
|
|
35
|
+
* does not corrupt the result.
|
|
36
|
+
*/
|
|
37
|
+
export function extractAddrSpec(entry: string): string {
|
|
38
|
+
const open = entry.lastIndexOf('<');
|
|
39
|
+
if (open !== -1) {
|
|
40
|
+
const close = entry.indexOf('>', open + 1);
|
|
41
|
+
if (close !== -1) return entry.slice(open + 1, close).trim();
|
|
42
|
+
}
|
|
43
|
+
return entry.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateEmailAddress(value: string, field: string): string {
|
|
47
|
+
const entries = value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
48
|
+
if (entries.length === 0 || entries.some((e) => !/.+@.+\..+/.test(extractAddrSpec(e)))) {
|
|
49
|
+
throw new HandlerError(`Field '${field}' must be a valid email address`, 'EMAIL_BAD_INPUT', 400);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clampLimit(value: unknown): number {
|
|
55
|
+
if (value === undefined || value === null) return 10;
|
|
56
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
57
|
+
if (!Number.isFinite(n)) {
|
|
58
|
+
throw new HandlerError("Field 'limit' must be a number", 'EMAIL_BAD_INPUT', 400);
|
|
59
|
+
}
|
|
60
|
+
return Math.min(100, Math.max(1, Math.floor(n)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function validateIsoDate(value: unknown): string | undefined {
|
|
64
|
+
const str = optionalString(value, 'since');
|
|
65
|
+
if (str === undefined) return undefined;
|
|
66
|
+
if (Number.isNaN(new Date(str).getTime())) {
|
|
67
|
+
throw new HandlerError("Field 'since' must be an ISO-8601 date", 'EMAIL_BAD_INPUT', 400);
|
|
68
|
+
}
|
|
69
|
+
return str;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function requireUid(value: unknown): number {
|
|
73
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
74
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
75
|
+
throw new HandlerError("Field 'uid' must be a positive integer", 'EMAIL_BAD_INPUT', 400);
|
|
76
|
+
}
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Reduce an address to a stable, non-reversible digest for PII-safe logging. */
|
|
81
|
+
export function addressDigest(address: string): string {
|
|
82
|
+
return createHash('sha256').update(address.toLowerCase().trim(), 'utf-8').digest('hex').slice(0, 16);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Response contracts (must match EMAIL_INBOX_MESSAGE_SCHEMA /
|
|
87
|
+
// EMAIL_MESSAGE_DETAIL_SCHEMA / {uid,draftId} / {messageId,sentAt}).
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export interface InboxListResponse {
|
|
91
|
+
messages: Array<{
|
|
92
|
+
uid: number;
|
|
93
|
+
from: string;
|
|
94
|
+
subject: string;
|
|
95
|
+
date: string;
|
|
96
|
+
unread: boolean;
|
|
97
|
+
bodyPreview: string;
|
|
98
|
+
messageId: string;
|
|
99
|
+
}>;
|
|
100
|
+
total: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface InboxReadResponse {
|
|
104
|
+
uid: number;
|
|
105
|
+
from: string;
|
|
106
|
+
subject: string;
|
|
107
|
+
date: string;
|
|
108
|
+
messageId: string;
|
|
109
|
+
bodyText: string;
|
|
110
|
+
bodyHtml?: string;
|
|
111
|
+
attachments?: Array<{ filename: string; contentType: string; sizeBytes: number }>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface DraftCreateResponse {
|
|
115
|
+
uid: number;
|
|
116
|
+
draftId: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SendResponse {
|
|
120
|
+
messageId: string;
|
|
121
|
+
sentAt: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function toListMessage(m: ImapEnvelopeSummary): InboxListResponse['messages'][number] {
|
|
125
|
+
return {
|
|
126
|
+
uid: m.uid,
|
|
127
|
+
from: m.from,
|
|
128
|
+
subject: m.subject,
|
|
129
|
+
date: m.date,
|
|
130
|
+
unread: m.unread,
|
|
131
|
+
bodyPreview: m.bodyPreview,
|
|
132
|
+
messageId: m.messageId,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function toReadMessage(m: ImapFullMessage): InboxReadResponse {
|
|
137
|
+
return {
|
|
138
|
+
uid: m.uid,
|
|
139
|
+
from: m.from,
|
|
140
|
+
subject: m.subject,
|
|
141
|
+
date: m.date,
|
|
142
|
+
messageId: m.messageId,
|
|
143
|
+
bodyText: m.bodyText,
|
|
144
|
+
...(m.bodyHtml ? { bodyHtml: m.bodyHtml } : {}),
|
|
145
|
+
...(m.attachments && m.attachments.length > 0 ? { attachments: m.attachments } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Write handlers: email.draft.create + email.send.
|
|
3
|
+
//
|
|
4
|
+
// email.draft.create is access:admin (no body.confirm in the SDK contract);
|
|
5
|
+
// email.send is dangerous:true and confirmation-gated — index.ts registers it
|
|
6
|
+
// with { confirm: true }, so the register wrapper enforces body.confirm===true
|
|
7
|
+
// AND explicitUserRequest before this handler runs.
|
|
8
|
+
//
|
|
9
|
+
// Recipients are validated, sender/recipient addresses are reduced to a digest
|
|
10
|
+
// before logging (PII strip), and draft bodies are encrypted at rest by the
|
|
11
|
+
// runtime before persistence. No secret or raw address is echoed into a
|
|
12
|
+
// response. No descriptor or schema is authored here.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
import type { CatalogHandlerEntry, TypedHandler } from '../register.ts';
|
|
16
|
+
import type { EmailRuntime } from './runtime.ts';
|
|
17
|
+
import { buildRfc5322Message, generateMessageId } from './smtp-connector.ts';
|
|
18
|
+
import {
|
|
19
|
+
asRecord,
|
|
20
|
+
requireString,
|
|
21
|
+
optionalString,
|
|
22
|
+
validateEmailAddress,
|
|
23
|
+
addressDigest,
|
|
24
|
+
type DraftCreateResponse,
|
|
25
|
+
type SendResponse,
|
|
26
|
+
} from './validation.ts';
|
|
27
|
+
|
|
28
|
+
interface DraftCreateBody {
|
|
29
|
+
to?: unknown;
|
|
30
|
+
subject?: unknown;
|
|
31
|
+
body?: unknown;
|
|
32
|
+
inReplyTo?: unknown;
|
|
33
|
+
references?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SendBody {
|
|
37
|
+
to?: unknown;
|
|
38
|
+
subject?: unknown;
|
|
39
|
+
body?: unknown;
|
|
40
|
+
inReplyTo?: unknown;
|
|
41
|
+
confirm?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function draftCreateHandler(runtime: EmailRuntime): TypedHandler<DraftCreateBody, DraftCreateResponse> {
|
|
45
|
+
return async ({ body }) => {
|
|
46
|
+
const input = asRecord(body);
|
|
47
|
+
const to = validateEmailAddress(requireString(input.to, 'to'), 'to');
|
|
48
|
+
const subject = requireString(input.subject, 'subject');
|
|
49
|
+
const draftBody = requireString(input.body, 'body');
|
|
50
|
+
const inReplyTo = optionalString(input.inReplyTo, 'inReplyTo');
|
|
51
|
+
const references = optionalString(input.references, 'references');
|
|
52
|
+
|
|
53
|
+
const from = await runtime.smtpFrom();
|
|
54
|
+
const messageId = generateMessageId(from);
|
|
55
|
+
const raw = buildRfc5322Message({
|
|
56
|
+
from,
|
|
57
|
+
to,
|
|
58
|
+
subject,
|
|
59
|
+
body: draftBody,
|
|
60
|
+
messageId,
|
|
61
|
+
date: new Date(),
|
|
62
|
+
...(inReplyTo ? { inReplyTo } : {}),
|
|
63
|
+
...(references ? { references } : {}),
|
|
64
|
+
});
|
|
65
|
+
const appended = await runtime.withImap((imap) => imap.appendDraft(raw));
|
|
66
|
+
|
|
67
|
+
const now = new Date().toISOString();
|
|
68
|
+
const draftId = messageId.replace(/[<>]/g, '');
|
|
69
|
+
await runtime.persistDraft({
|
|
70
|
+
id: draftId,
|
|
71
|
+
to,
|
|
72
|
+
subject,
|
|
73
|
+
plaintextBody: draftBody,
|
|
74
|
+
status: 'draft',
|
|
75
|
+
createdAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
metadata: { uid: appended.uid, mailbox: appended.mailbox, messageId },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
runtime.logger.info('email.draft.create', {
|
|
81
|
+
uid: appended.uid,
|
|
82
|
+
draftId,
|
|
83
|
+
recipient: addressDigest(to),
|
|
84
|
+
});
|
|
85
|
+
return { uid: appended.uid, draftId };
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sendHandler(runtime: EmailRuntime): TypedHandler<SendBody, SendResponse> {
|
|
90
|
+
return async ({ body }) => {
|
|
91
|
+
const input = asRecord(body);
|
|
92
|
+
const to = validateEmailAddress(requireString(input.to, 'to'), 'to');
|
|
93
|
+
const subject = requireString(input.subject, 'subject');
|
|
94
|
+
const sendBody = requireString(input.body, 'body');
|
|
95
|
+
const inReplyTo = optionalString(input.inReplyTo, 'inReplyTo');
|
|
96
|
+
|
|
97
|
+
const smtp = await runtime.openSmtp();
|
|
98
|
+
let result: SendResponse;
|
|
99
|
+
try {
|
|
100
|
+
result = await smtp.send({
|
|
101
|
+
to,
|
|
102
|
+
subject,
|
|
103
|
+
body: sendBody,
|
|
104
|
+
...(inReplyTo ? { inReplyTo } : {}),
|
|
105
|
+
});
|
|
106
|
+
} finally {
|
|
107
|
+
await smtp.close();
|
|
108
|
+
}
|
|
109
|
+
runtime.logger.info('email.send', {
|
|
110
|
+
messageId: result.messageId,
|
|
111
|
+
sentAt: result.sentAt,
|
|
112
|
+
recipient: addressDigest(to),
|
|
113
|
+
});
|
|
114
|
+
return result;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Build the write-handler catalog entries bound to the shared runtime. */
|
|
119
|
+
export function buildWriteHandlerEntries(runtime: EmailRuntime): CatalogHandlerEntry[] {
|
|
120
|
+
return [
|
|
121
|
+
{
|
|
122
|
+
id: 'email.draft.create',
|
|
123
|
+
handler: draftCreateHandler(runtime) as TypedHandler<unknown, unknown>,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'email.send',
|
|
127
|
+
handler: sendHandler(runtime) as TypedHandler<unknown, unknown>,
|
|
128
|
+
// dangerous:true SDK method — require explicit confirmation (body.confirm
|
|
129
|
+
// === true AND explicitUserRequest) via the register wrapper.
|
|
130
|
+
options: { confirm: true },
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error type for daemon handler execution. Carries a stable machine `code`
|
|
3
|
+
* and an HTTP `status` so the control-plane dispatcher can map failures to
|
|
4
|
+
* wire responses without leaking implementation detail.
|
|
5
|
+
*/
|
|
6
|
+
export class HandlerError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
message: string,
|
|
9
|
+
public readonly code: string,
|
|
10
|
+
public readonly status: number,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'HandlerError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Code emitted when a confirmation-gated method is invoked without explicit user confirmation. */
|
|
18
|
+
export const REQUIRE_CONFIRM = 'REQUIRE_CONFIRM';
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// Persistent cursor + item store for the inbound feed.
|
|
3
3
|
//
|
|
4
|
-
// Backed by
|
|
4
|
+
// Backed by HandlerSqliteStore (sql.js WASM) at
|
|
5
5
|
// {wd}/.goodvibes/tui/operator/inbox.sqlite
|
|
6
6
|
//
|
|
7
7
|
// Two tables:
|
|
8
8
|
// items(id PK, provider, kind, fromDigest, subjectPreview, bodyPreview,
|
|
9
|
-
// routeId, receivedAt INT, unread INT
|
|
10
|
-
// triageTags TEXT NULL)
|
|
9
|
+
// routeId, receivedAt INT, unread INT)
|
|
11
10
|
// cursors(provider PK, nextSince INT)
|
|
12
11
|
//
|
|
13
|
-
// Dedup is by items.id (
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// Dedup is by items.id (upsert). nextSince advances monotonically per provider
|
|
13
|
+
// = max(receivedAt) ever seen. Triage metadata is NOT persisted here: it is
|
|
14
|
+
// applied downstream at the triage overlay layer (triage/integration.ts) over a
|
|
15
|
+
// separate store, so this feed store carries only the raw inbound fields.
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
19
19
|
import type { InboundChannelItem } from './provider-adapter.ts';
|
|
20
20
|
|
|
21
21
|
const SCHEMA: string[] = [
|
|
@@ -28,9 +28,7 @@ const SCHEMA: string[] = [
|
|
|
28
28
|
bodyPreview TEXT NOT NULL,
|
|
29
29
|
routeId TEXT,
|
|
30
30
|
receivedAt INTEGER NOT NULL,
|
|
31
|
-
unread INTEGER NOT NULL
|
|
32
|
-
triageScore REAL,
|
|
33
|
-
triageTags TEXT
|
|
31
|
+
unread INTEGER NOT NULL
|
|
34
32
|
)`,
|
|
35
33
|
`CREATE INDEX IF NOT EXISTS idx_items_provider_received
|
|
36
34
|
ON items(provider, receivedAt)`,
|
|
@@ -52,8 +50,6 @@ interface ItemRow {
|
|
|
52
50
|
routeId: string | null;
|
|
53
51
|
receivedAt: number;
|
|
54
52
|
unread: number;
|
|
55
|
-
triageScore: number | null;
|
|
56
|
-
triageTags: string | null;
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
export interface InboxQuery {
|
|
@@ -64,11 +60,11 @@ export interface InboxQuery {
|
|
|
64
60
|
}
|
|
65
61
|
|
|
66
62
|
export class InboxCursorStore {
|
|
67
|
-
private readonly store:
|
|
63
|
+
private readonly store: HandlerSqliteStore;
|
|
68
64
|
private dirty = false;
|
|
69
65
|
|
|
70
66
|
constructor(workingDirectory: string, fileName = 'inbox.sqlite') {
|
|
71
|
-
this.store = new
|
|
67
|
+
this.store = new HandlerSqliteStore({
|
|
72
68
|
workingDirectory,
|
|
73
69
|
fileName,
|
|
74
70
|
schema: SCHEMA,
|
|
@@ -84,29 +80,39 @@ export class InboxCursorStore {
|
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
/**
|
|
87
|
-
* Insert/refresh items, deduping by id.
|
|
88
|
-
*
|
|
89
|
-
* mutable feed fields (unread, previews, routeId, receivedAt) are updated.
|
|
83
|
+
* Insert/refresh items, deduping by id. On conflict the mutable feed fields
|
|
84
|
+
* (unread, previews, routeId, receivedAt) are updated in place.
|
|
90
85
|
* Returns the number of NEW (previously unseen) items.
|
|
91
86
|
*/
|
|
92
87
|
upsertItems(items: readonly InboundChannelItem[]): number {
|
|
93
88
|
if (items.length === 0) return 0;
|
|
94
89
|
let inserted = 0;
|
|
90
|
+
// Only the ids in THIS batch can collide, so probe for exactly those rather
|
|
91
|
+
// than loading the whole table — bounds the lookup to the poll size instead
|
|
92
|
+
// of growing O(n) with the (unbounded) feed.
|
|
93
|
+
const batchIds = [...new Set(items.map((i) => i.id))];
|
|
94
|
+
const placeholders = batchIds.map(() => '?').join(', ');
|
|
95
95
|
const existing = new Set(
|
|
96
96
|
this.store
|
|
97
|
-
.all<{ id: string }>(
|
|
97
|
+
.all<{ id: string }>(
|
|
98
|
+
`SELECT id FROM items WHERE id IN (${placeholders})`,
|
|
99
|
+
batchIds,
|
|
100
|
+
)
|
|
98
101
|
.map((r) => r.id),
|
|
99
102
|
);
|
|
103
|
+
// Track ids seen within this batch so a duplicate id in a single poll is
|
|
104
|
+
// counted (and inserted) once, not once per occurrence.
|
|
105
|
+
const seen = new Set<string>();
|
|
100
106
|
this.store.transaction(() => {
|
|
101
107
|
for (const item of items) {
|
|
102
|
-
const isNew = !existing.has(item.id);
|
|
108
|
+
const isNew = !existing.has(item.id) && !seen.has(item.id);
|
|
103
109
|
if (isNew) inserted += 1;
|
|
104
|
-
|
|
110
|
+
seen.add(item.id);
|
|
105
111
|
this.store.run(
|
|
106
112
|
`INSERT INTO items
|
|
107
113
|
(id, provider, kind, fromDigest, subjectPreview, bodyPreview,
|
|
108
|
-
routeId, receivedAt, unread
|
|
109
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
|
114
|
+
routeId, receivedAt, unread)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
110
116
|
ON CONFLICT(id) DO UPDATE SET
|
|
111
117
|
provider = excluded.provider,
|
|
112
118
|
kind = excluded.kind,
|
|
@@ -130,7 +136,7 @@ export class InboxCursorStore {
|
|
|
130
136
|
);
|
|
131
137
|
}
|
|
132
138
|
});
|
|
133
|
-
|
|
139
|
+
this.dirty = true;
|
|
134
140
|
return inserted;
|
|
135
141
|
}
|
|
136
142
|
|
|
@@ -152,20 +158,6 @@ export class InboxCursorStore {
|
|
|
152
158
|
this.dirty = true;
|
|
153
159
|
}
|
|
154
160
|
|
|
155
|
-
/**
|
|
156
|
-
* Write triage metadata for an existing item. Provided so the triage surface
|
|
157
|
-
* has a typed, scoped path to populate the triageScore/triageTags columns this
|
|
158
|
-
* store reads back (no raw SQL needed by callers). No-op when the id is unknown.
|
|
159
|
-
*/
|
|
160
|
-
applyTriage(id: string, triageScore: number | null, triageTags: readonly string[] | null): void {
|
|
161
|
-
const tags = triageTags && triageTags.length > 0 ? JSON.stringify(triageTags) : null;
|
|
162
|
-
this.store.run(
|
|
163
|
-
'UPDATE items SET triageScore = ?, triageTags = ? WHERE id = ?',
|
|
164
|
-
[triageScore, tags, id],
|
|
165
|
-
);
|
|
166
|
-
this.dirty = true;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
161
|
/** Current cursor for a provider (0 when unset). */
|
|
170
162
|
getCursor(provider: string): number {
|
|
171
163
|
const row = this.store.get<{ nextSince: number }>(
|
|
@@ -177,7 +169,7 @@ export class InboxCursorStore {
|
|
|
177
169
|
|
|
178
170
|
/**
|
|
179
171
|
* Read items for the feed, filtered by provider set + since, newest first,
|
|
180
|
-
* capped at limit. Maps SQLite rows back to the
|
|
172
|
+
* capped at limit. Maps SQLite rows back to the internal item shape.
|
|
181
173
|
*/
|
|
182
174
|
listItems(query: InboxQuery): InboundChannelItem[] {
|
|
183
175
|
const clauses: string[] = [];
|
|
@@ -196,7 +188,7 @@ export class InboxCursorStore {
|
|
|
196
188
|
params.push(limit);
|
|
197
189
|
const rows = this.store.all<ItemRow>(
|
|
198
190
|
`SELECT id, provider, kind, fromDigest, subjectPreview, bodyPreview,
|
|
199
|
-
routeId, receivedAt, unread
|
|
191
|
+
routeId, receivedAt, unread
|
|
200
192
|
FROM items ${where}
|
|
201
193
|
ORDER BY receivedAt DESC, id ASC
|
|
202
194
|
LIMIT ?`,
|
|
@@ -221,8 +213,8 @@ export class InboxCursorStore {
|
|
|
221
213
|
return row && row.maxReceived != null ? Number(row.maxReceived) : 0;
|
|
222
214
|
}
|
|
223
215
|
|
|
224
|
-
/**
|
|
225
|
-
|
|
216
|
+
/** Total count of items across the (optional) provider set. */
|
|
217
|
+
countItems(providers?: readonly string[]): number {
|
|
226
218
|
let where = '';
|
|
227
219
|
const params: (string | number)[] = [];
|
|
228
220
|
if (providers && providers.length > 0) {
|
|
@@ -230,13 +222,33 @@ export class InboxCursorStore {
|
|
|
230
222
|
where = `WHERE provider IN (${placeholders})`;
|
|
231
223
|
params.push(...providers);
|
|
232
224
|
}
|
|
233
|
-
const
|
|
234
|
-
`SELECT
|
|
225
|
+
const row = this.store.get<{ n: number }>(
|
|
226
|
+
`SELECT COUNT(*) AS n FROM items ${where}`,
|
|
235
227
|
params,
|
|
236
228
|
);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
229
|
+
return row ? Number(row.n) : 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Retention: delete items strictly older than `cutoff` (Unix ms), optionally
|
|
234
|
+
* scoped to a provider set. Returns the number of rows removed. Cursors are
|
|
235
|
+
* left untouched so already-consumed watermarks never regress. A non-finite
|
|
236
|
+
* cutoff is a no-op (prevents an accidental whole-table wipe).
|
|
237
|
+
*/
|
|
238
|
+
pruneOlderThan(cutoff: number, providers?: readonly string[]): number {
|
|
239
|
+
if (!Number.isFinite(cutoff)) return 0;
|
|
240
|
+
const clauses = ['receivedAt < ?'];
|
|
241
|
+
const params: (string | number)[] = [Math.floor(cutoff)];
|
|
242
|
+
if (providers && providers.length > 0) {
|
|
243
|
+
const placeholders = providers.map(() => '?').join(', ');
|
|
244
|
+
clauses.push(`provider IN (${placeholders})`);
|
|
245
|
+
params.push(...providers);
|
|
246
|
+
}
|
|
247
|
+
const before = this.countItems(providers);
|
|
248
|
+
this.store.run(`DELETE FROM items WHERE ${clauses.join(' AND ')}`, params);
|
|
249
|
+
const removed = before - this.countItems(providers);
|
|
250
|
+
if (removed > 0) this.dirty = true;
|
|
251
|
+
return removed;
|
|
240
252
|
}
|
|
241
253
|
|
|
242
254
|
/** Persist to disk if anything changed since the last flush. */
|
|
@@ -268,11 +280,6 @@ function rowToItem(row: ItemRow): InboundChannelItem {
|
|
|
268
280
|
unread: Number(row.unread) !== 0,
|
|
269
281
|
};
|
|
270
282
|
if (row.routeId != null) item.routeId = row.routeId;
|
|
271
|
-
if (row.triageScore != null) item.triageScore = Number(row.triageScore);
|
|
272
|
-
if (row.triageTags != null) {
|
|
273
|
-
const tags = parseTags(row.triageTags);
|
|
274
|
-
if (tags.length > 0) item.triageTags = tags;
|
|
275
|
-
}
|
|
276
283
|
return item;
|
|
277
284
|
}
|
|
278
285
|
|
|
@@ -281,18 +288,3 @@ function normalizeKind(value: string): InboundChannelItem['kind'] {
|
|
|
281
288
|
? value
|
|
282
289
|
: 'dm';
|
|
283
290
|
}
|
|
284
|
-
|
|
285
|
-
function parseTags(raw: string): string[] {
|
|
286
|
-
try {
|
|
287
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
288
|
-
if (Array.isArray(parsed)) {
|
|
289
|
-
return parsed.filter((t): t is string => typeof t === 'string');
|
|
290
|
-
}
|
|
291
|
-
} catch {
|
|
292
|
-
// Fall through to comma-split fallback for non-JSON legacy values.
|
|
293
|
-
}
|
|
294
|
-
return raw
|
|
295
|
-
.split(',')
|
|
296
|
-
.map((t) => t.trim())
|
|
297
|
-
.filter((t) => t.length > 0);
|
|
298
|
-
}
|