@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,383 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Triage tagger — IMAP provider.
|
|
3
|
+
//
|
|
4
|
+
// Applies a triage label as an IMAP keyword flag (STORE +FLAGS) over a minimal
|
|
5
|
+
// IMAP4rev1-over-TLS client. Credentials are resolved per-apply from the daemon
|
|
6
|
+
// credential store and are NEVER logged or returned. CRLF/control characters in
|
|
7
|
+
// any interpolated command value are rejected (CRLF-injection guard). Transient
|
|
8
|
+
// network failures are retried with bounded exponential backoff; protocol-level
|
|
9
|
+
// NO/BAD rejections are deterministic and never retried.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
13
|
+
import type { DaemonCredentialStore } from '../../credentials.ts';
|
|
14
|
+
import type { InboundChannelItem } from '../types.ts';
|
|
15
|
+
import type { ApplyTagsResult, TaggerProviderConfig } from './shared.ts';
|
|
16
|
+
import { imapKeywordForTag } from './shared.ts';
|
|
17
|
+
|
|
18
|
+
export interface ImapStoreArgs {
|
|
19
|
+
host: string;
|
|
20
|
+
port: number;
|
|
21
|
+
user: string;
|
|
22
|
+
password: string;
|
|
23
|
+
mailbox: string;
|
|
24
|
+
uid: string;
|
|
25
|
+
flag: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ImapRetryOptions {
|
|
29
|
+
/** Total attempts including the first (default 3). Values < 1 disable retry. */
|
|
30
|
+
maxAttempts?: number;
|
|
31
|
+
/** Base backoff in ms for the first retry (default 250). */
|
|
32
|
+
baseDelayMs?: number;
|
|
33
|
+
/** Cap on any single backoff delay in ms (default 2000). */
|
|
34
|
+
maxDelayMs?: number;
|
|
35
|
+
/** Injectable sleep (tests). Defaults to a real timer. */
|
|
36
|
+
sleep?: (ms: number) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ImapStoreFlag = (args: ImapStoreArgs) => Promise<void>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The minimal subset of a node:tls TLSSocket that imapStoreFlagOverTls drives.
|
|
43
|
+
* Declaring it explicitly (rather than depending on the full TLSSocket surface)
|
|
44
|
+
* gives the data-pump a precise injection seam: tests can supply an in-memory
|
|
45
|
+
* duplex that exercises the protocol state machine without a real network
|
|
46
|
+
* socket, while the production path passes the real tlsConnect result.
|
|
47
|
+
*/
|
|
48
|
+
export interface ImapSocketLike {
|
|
49
|
+
setEncoding(encoding: string): void;
|
|
50
|
+
setTimeout(ms: number, callback: () => void): void;
|
|
51
|
+
write(data: string): void;
|
|
52
|
+
on(event: 'data', listener: (chunk: string) => void): void;
|
|
53
|
+
on(event: 'error', listener: (err: Error) => void): void;
|
|
54
|
+
on(event: 'close', listener: () => void): void;
|
|
55
|
+
destroy(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Connector seam: opens an ImapSocketLike for the given host/port. */
|
|
59
|
+
export type ImapConnect = (opts: {
|
|
60
|
+
host: string;
|
|
61
|
+
port: number;
|
|
62
|
+
servername: string;
|
|
63
|
+
}) => ImapSocketLike;
|
|
64
|
+
|
|
65
|
+
/** Production connector — a real IMAP4rev1-over-TLS socket via node:tls. */
|
|
66
|
+
export const tlsImapConnect: ImapConnect = (opts) =>
|
|
67
|
+
tlsConnect(opts, () => {
|
|
68
|
+
/* greeting handled in the data pump */
|
|
69
|
+
}) as unknown as ImapSocketLike;
|
|
70
|
+
|
|
71
|
+
export async function applyImap(
|
|
72
|
+
item: InboundChannelItem,
|
|
73
|
+
tags: string[],
|
|
74
|
+
providers: TaggerProviderConfig,
|
|
75
|
+
credentials: DaemonCredentialStore,
|
|
76
|
+
storeFlag: ImapStoreFlag,
|
|
77
|
+
base: ApplyTagsResult,
|
|
78
|
+
): Promise<ApplyTagsResult> {
|
|
79
|
+
const cfg = providers.imap;
|
|
80
|
+
if (!cfg) return { ...base, reason: 'imap-not-configured' };
|
|
81
|
+
const uid = imapUidFromItem(item);
|
|
82
|
+
if (!uid) return { ...base, reason: 'imap-missing-uid' };
|
|
83
|
+
if (tags.length === 0) return { ...base, reason: 'no-tags' };
|
|
84
|
+
|
|
85
|
+
const password = await credentials.resolveConfigSecret(cfg.passwordConfigKey);
|
|
86
|
+
if (!password) return { ...base, reason: 'imap-no-credentials' };
|
|
87
|
+
|
|
88
|
+
const applied: string[] = [];
|
|
89
|
+
for (const tag of tags) {
|
|
90
|
+
await storeFlag({
|
|
91
|
+
host: cfg.host,
|
|
92
|
+
port: cfg.port ?? 993,
|
|
93
|
+
user: cfg.user,
|
|
94
|
+
password,
|
|
95
|
+
mailbox: cfg.mailbox ?? 'INBOX',
|
|
96
|
+
uid,
|
|
97
|
+
flag: imapKeywordForTag(tag),
|
|
98
|
+
});
|
|
99
|
+
applied.push(tag);
|
|
100
|
+
}
|
|
101
|
+
return { surface: item.surface, itemId: item.id, appliedTags: applied, skipped: false };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function imapUidFromItem(item: InboundChannelItem): string | null {
|
|
105
|
+
const meta = item.metadata ?? {};
|
|
106
|
+
const uid = meta.imapUid ?? meta.uid;
|
|
107
|
+
if (typeof uid === 'string' && uid.length > 0) return uid;
|
|
108
|
+
if (typeof uid === 'number' && Number.isFinite(uid)) return String(uid);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Error subclass carrying whether a failure is transient (worth retrying) or a
|
|
114
|
+
* deterministic protocol rejection (NO/BAD) that must not be retried.
|
|
115
|
+
*/
|
|
116
|
+
export class ImapStoreError extends Error {
|
|
117
|
+
readonly transient: boolean;
|
|
118
|
+
constructor(message: string, transient: boolean) {
|
|
119
|
+
super(message);
|
|
120
|
+
this.name = 'ImapStoreError';
|
|
121
|
+
this.transient = transient;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reject CR, LF, NUL and any other ASCII control character (0x00-0x1F, 0x7F)
|
|
127
|
+
* in a value destined for an IMAP command line. CR/LF are the dangerous ones:
|
|
128
|
+
* an unescaped CRLF would terminate the current command and let an attacker
|
|
129
|
+
* inject a second IMAP command (CRLF injection). IMAP's quoted-string syntax
|
|
130
|
+
* has no escape for these control chars — backslash only escapes `\` and `"` —
|
|
131
|
+
* so the only safe handling is to refuse the value outright.
|
|
132
|
+
*/
|
|
133
|
+
function assertImapSafe(value: string, field: string): void {
|
|
134
|
+
// eslint-disable-next-line no-control-regex -- intentional control-char guard
|
|
135
|
+
if (/[\x00-\x1F\x7F]/.test(value)) {
|
|
136
|
+
throw new ImapStoreError(
|
|
137
|
+
`IMAP ${field} contains illegal control characters (possible CRLF injection)`,
|
|
138
|
+
false,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wrap a value as an IMAP quoted string. Backslash and double-quote are escaped
|
|
145
|
+
* per RFC 3501; control characters are NOT escapable in the quoted-string
|
|
146
|
+
* grammar, so callers MUST validate with assertImapSafe first. quoteImap
|
|
147
|
+
* re-asserts as defense-in-depth so no future caller can bypass the guard.
|
|
148
|
+
*/
|
|
149
|
+
function quoteImap(value: string): string {
|
|
150
|
+
assertImapSafe(value, 'value');
|
|
151
|
+
return `"${value.replace(/([\\"])/g, '\\$1')}"`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A concrete UID sequence-set: one or more comma-separated single UIDs or
|
|
155
|
+
// numeric ranges (`12`, `3:9`, `1,4,7:9`). RFC 3501 also permits `*` (the
|
|
156
|
+
// largest UID in the mailbox) and ranges to `*`, but a wildcard would let a
|
|
157
|
+
// single value like `1:*` apply the flag to the ENTIRE mailbox — never the
|
|
158
|
+
// intent when tagging one triaged item — so `*` is rejected outright.
|
|
159
|
+
const IMAP_UID_SET = /^[0-9]+(:[0-9]+)?(,[0-9]+(:[0-9]+)?)*$/;
|
|
160
|
+
|
|
161
|
+
// An IMAP flag: an optional leading `\` (system flag, e.g. `\Seen`) followed by
|
|
162
|
+
// atom characters only. The atom grammar excludes SP and the list/quoting
|
|
163
|
+
// specials `(){%*"\` and `]`, so a flag cannot contain a space (which would
|
|
164
|
+
// otherwise inject a second flag atom into the `+FLAGS (...)` list) or close
|
|
165
|
+
// the parenthesised list early.
|
|
166
|
+
// eslint-disable-next-line no-control-regex -- atom grammar excludes controls
|
|
167
|
+
const IMAP_FLAG = /^\\?[^\s(){%*"\\\]\x00-\x1F\x7F]+$/;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Validate an IMAP UID value as a concrete sequence-set before it is
|
|
171
|
+
* interpolated UNQUOTED into `UID STORE`. assertImapSafe only blocks control
|
|
172
|
+
* chars; a control-char-free wildcard like `1:*` would still pass that guard
|
|
173
|
+
* and mutate the whole mailbox. This is the boundary check that prevents it.
|
|
174
|
+
*/
|
|
175
|
+
function assertImapUid(value: string): void {
|
|
176
|
+
if (!IMAP_UID_SET.test(value)) {
|
|
177
|
+
throw new ImapStoreError(
|
|
178
|
+
`IMAP uid is not a valid numeric sequence-set: ${JSON.stringify(value)}`,
|
|
179
|
+
false,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Validate an IMAP flag against the flag/atom grammar before it is interpolated
|
|
186
|
+
* UNQUOTED into the `+FLAGS (...)` list. assertImapSafe only blocks control
|
|
187
|
+
* chars; a flag containing a space (e.g. `\Seen Junk`) would still pass that
|
|
188
|
+
* guard and inject a second flag atom. This is the boundary check that
|
|
189
|
+
* prevents it — independent of any upstream normalizer.
|
|
190
|
+
*/
|
|
191
|
+
function assertImapFlag(value: string): void {
|
|
192
|
+
if (!IMAP_FLAG.test(value)) {
|
|
193
|
+
throw new ImapStoreError(
|
|
194
|
+
`IMAP flag is not a valid flag-keyword atom: ${JSON.stringify(value)}`,
|
|
195
|
+
false,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Minimal IMAP4rev1 client: connect over TLS, LOGIN, SELECT, UID STORE +FLAGS,
|
|
202
|
+
* LOGOUT. Uses only node:tls (Bun-compatible).
|
|
203
|
+
*
|
|
204
|
+
* Tagged-command sequencing uses an explicit completion flag rather than a line
|
|
205
|
+
* heuristic: the LOGOUT step is tracked by reference, so its tagged OK (or an
|
|
206
|
+
* untagged `* BYE`) cleanly completes the operation and tells the `close`
|
|
207
|
+
* handler the disconnect was expected. Connection/timeout/socket failures are
|
|
208
|
+
* surfaced as transient ImapStoreError; NO/BAD protocol responses as
|
|
209
|
+
* non-transient.
|
|
210
|
+
*/
|
|
211
|
+
export function imapStoreFlagOverTls(
|
|
212
|
+
args: ImapStoreArgs,
|
|
213
|
+
connect: ImapConnect = tlsImapConnect,
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
return new Promise<void>((resolve, reject) => {
|
|
216
|
+
try {
|
|
217
|
+
assertImapSafe(args.user, 'user');
|
|
218
|
+
assertImapSafe(args.password, 'password');
|
|
219
|
+
assertImapSafe(args.mailbox, 'mailbox');
|
|
220
|
+
assertImapSafe(args.uid, 'uid');
|
|
221
|
+
assertImapSafe(args.flag, 'flag');
|
|
222
|
+
// uid and flag are interpolated UNQUOTED below; the control-char guard
|
|
223
|
+
// alone is insufficient, so enforce the structural grammar here too.
|
|
224
|
+
assertImapUid(args.uid);
|
|
225
|
+
assertImapFlag(args.flag);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
reject(
|
|
228
|
+
err instanceof ImapStoreError
|
|
229
|
+
? err
|
|
230
|
+
: new ImapStoreError(err instanceof Error ? err.message : String(err), false),
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
type Step = { tag: string; isLogout: boolean; resolve: () => void; reject: (e: Error) => void };
|
|
235
|
+
|
|
236
|
+
let done = false;
|
|
237
|
+
let completed = false; // LOGOUT acknowledged (or BYE seen) — close is expected.
|
|
238
|
+
let buffer = '';
|
|
239
|
+
let pending: Step | null = null;
|
|
240
|
+
let counter = 0;
|
|
241
|
+
let greeted = false;
|
|
242
|
+
|
|
243
|
+
const finish = (err?: Error): void => {
|
|
244
|
+
if (done) return;
|
|
245
|
+
done = true;
|
|
246
|
+
try {
|
|
247
|
+
socket.destroy();
|
|
248
|
+
} catch {
|
|
249
|
+
/* ignore */
|
|
250
|
+
}
|
|
251
|
+
if (err) reject(err);
|
|
252
|
+
else resolve();
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const socket: ImapSocketLike = connect({
|
|
256
|
+
host: args.host,
|
|
257
|
+
port: args.port,
|
|
258
|
+
servername: args.host,
|
|
259
|
+
});
|
|
260
|
+
socket.setEncoding('utf-8');
|
|
261
|
+
socket.setTimeout(20_000, () =>
|
|
262
|
+
finish(new ImapStoreError('IMAP connection timed out', true)),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const send = (command: string, isLogout = false): Promise<void> =>
|
|
266
|
+
new Promise<void>((res, rej) => {
|
|
267
|
+
counter += 1;
|
|
268
|
+
const tag = `A${counter}`;
|
|
269
|
+
pending = { tag, isLogout, resolve: res, reject: rej };
|
|
270
|
+
socket.write(`${tag} ${command}\r\n`);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const runSequence = async (): Promise<void> => {
|
|
274
|
+
await send(`LOGIN ${quoteImap(args.user)} ${quoteImap(args.password)}`);
|
|
275
|
+
await send(`SELECT ${quoteImap(args.mailbox)}`);
|
|
276
|
+
await send(`UID STORE ${args.uid} +FLAGS (${args.flag})`);
|
|
277
|
+
await send('LOGOUT', true);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const handleLine = (line: string): void => {
|
|
281
|
+
if (!greeted) {
|
|
282
|
+
greeted = true;
|
|
283
|
+
if (!/^\* (OK|PREAUTH)/i.test(line)) {
|
|
284
|
+
finish(new ImapStoreError(`IMAP server rejected connection: ${line}`, true));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
runSequence().catch((err) =>
|
|
288
|
+
finish(
|
|
289
|
+
err instanceof ImapStoreError
|
|
290
|
+
? err
|
|
291
|
+
: new ImapStoreError(err instanceof Error ? err.message : String(err), false),
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// An untagged BYE during LOGOUT is the server announcing a clean close.
|
|
298
|
+
if (/^\* BYE/i.test(line) && pending?.isLogout) {
|
|
299
|
+
completed = true;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!pending) return;
|
|
304
|
+
if (!line.startsWith(`${pending.tag} `)) return; // untagged data line
|
|
305
|
+
const status = line.slice(pending.tag.length + 1);
|
|
306
|
+
const current = pending;
|
|
307
|
+
pending = null;
|
|
308
|
+
if (/^OK/i.test(status)) {
|
|
309
|
+
current.resolve();
|
|
310
|
+
if (current.isLogout) {
|
|
311
|
+
completed = true;
|
|
312
|
+
finish();
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
// NO/BAD: deterministic protocol rejection — do not retry.
|
|
316
|
+
current.reject(new ImapStoreError(`IMAP command failed: ${status}`, false));
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
socket.on('error', (err) =>
|
|
321
|
+
finish(new ImapStoreError(err instanceof Error ? err.message : String(err), true)),
|
|
322
|
+
);
|
|
323
|
+
socket.on('close', () => {
|
|
324
|
+
if (completed) finish();
|
|
325
|
+
else finish(new ImapStoreError('IMAP connection closed unexpectedly', true));
|
|
326
|
+
});
|
|
327
|
+
socket.on('data', (chunk: string) => {
|
|
328
|
+
buffer += chunk;
|
|
329
|
+
let newlineIdx = buffer.indexOf('\n');
|
|
330
|
+
while (newlineIdx !== -1) {
|
|
331
|
+
const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
|
|
332
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
333
|
+
handleLine(line);
|
|
334
|
+
newlineIdx = buffer.indexOf('\n');
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** True when an error is worth retrying (network/connection, not protocol). */
|
|
341
|
+
function isTransientImapError(error: unknown): boolean {
|
|
342
|
+
if (error instanceof ImapStoreError) return error.transient;
|
|
343
|
+
const code = (error as { code?: unknown })?.code;
|
|
344
|
+
if (typeof code === 'string') {
|
|
345
|
+
return ['ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN']
|
|
346
|
+
.includes(code);
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
352
|
+
new Promise((res) => setTimeout(res, ms));
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Wrap an IMAP store function with bounded exponential-backoff retry on
|
|
356
|
+
* transient failures only. Protocol-level (NO/BAD) errors are surfaced on the
|
|
357
|
+
* first attempt without retry.
|
|
358
|
+
*/
|
|
359
|
+
export function makeRetryingImapStoreFlag(
|
|
360
|
+
inner: ImapStoreFlag,
|
|
361
|
+
retry: ImapRetryOptions = {},
|
|
362
|
+
): ImapStoreFlag {
|
|
363
|
+
const maxAttempts = Math.max(1, retry.maxAttempts ?? 3);
|
|
364
|
+
const baseDelayMs = Math.max(0, retry.baseDelayMs ?? 250);
|
|
365
|
+
const maxDelayMs = Math.max(baseDelayMs, retry.maxDelayMs ?? 2_000);
|
|
366
|
+
const sleep = retry.sleep ?? defaultSleep;
|
|
367
|
+
|
|
368
|
+
return async (args: ImapStoreArgs): Promise<void> => {
|
|
369
|
+
let lastError: unknown;
|
|
370
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
371
|
+
try {
|
|
372
|
+
await inner(args);
|
|
373
|
+
return;
|
|
374
|
+
} catch (error) {
|
|
375
|
+
lastError = error;
|
|
376
|
+
if (attempt >= maxAttempts || !isTransientImapError(error)) throw error;
|
|
377
|
+
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
|
|
378
|
+
await sleep(delay);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
throw lastError;
|
|
382
|
+
};
|
|
383
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Daemon-internal triage TAGGER (composition).
|
|
3
|
+
//
|
|
4
|
+
// Applies user-defined triage labels back on the provider side:
|
|
5
|
+
// - IMAP : STORE a keyword flag on the message (IMAP4rev1 over TLS).
|
|
6
|
+
// - Slack: reactions.add emoji on the source message.
|
|
7
|
+
// - Discord: real forum thread tags (PATCH applied_tags, merge) when a
|
|
8
|
+
// forum-tag mapping is configured, else a unicode reaction analog.
|
|
9
|
+
//
|
|
10
|
+
// Hard rules honored here:
|
|
11
|
+
// - All provider credentials come ONLY from the daemon credential store.
|
|
12
|
+
// They are never returned in results and never logged.
|
|
13
|
+
// - The whole tagger is gated behind a config flag (surfaces.triage.autoTag);
|
|
14
|
+
// when disabled, applyTags() is a no-op that reports skipped:true.
|
|
15
|
+
// - Provider-side writes are EFFECTFUL; callers must pass an explicitly
|
|
16
|
+
// confirmed request (confirm === true && explicitUserRequest === true).
|
|
17
|
+
// Unconfirmed calls throw HandlerError(REQUIRE_CONFIRM).
|
|
18
|
+
//
|
|
19
|
+
// Contract-fidelity note: the triage surface (`inbox.triage.*`) is daemon-
|
|
20
|
+
// internal and NOT a published operator method, so there is no external
|
|
21
|
+
// request/response schema to certify these tag shapes against. What is
|
|
22
|
+
// guaranteed is the provider-side behavior: no silent data loss (Discord thread
|
|
23
|
+
// tags are merged, never blindly overwritten) and no command injection (IMAP
|
|
24
|
+
// quoting rejects control characters).
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
import type { HandlerContext } from '../../context.ts';
|
|
28
|
+
import type { DaemonCredentialStore } from '../../credentials.ts';
|
|
29
|
+
import { HandlerError, REQUIRE_CONFIRM } from '../../errors.ts';
|
|
30
|
+
import { labelToTag } from '../scorer.ts';
|
|
31
|
+
import { applyImap, imapStoreFlagOverTls, makeRetryingImapStoreFlag } from './imap.ts';
|
|
32
|
+
import type { ImapRetryOptions, ImapStoreFlag } from './imap.ts';
|
|
33
|
+
import { applySlack } from './slack.ts';
|
|
34
|
+
import { applyDiscord } from './discord.ts';
|
|
35
|
+
import type { ApplyTagsRequest, ApplyTagsResult, TaggerProviderConfig } from './shared.ts';
|
|
36
|
+
|
|
37
|
+
export type {
|
|
38
|
+
ApplyTagsRequest,
|
|
39
|
+
ApplyTagsResult,
|
|
40
|
+
TaggerProviderConfig,
|
|
41
|
+
} from './shared.ts';
|
|
42
|
+
export type { ImapRetryOptions, ImapStoreArgs, ImapStoreFlag } from './imap.ts';
|
|
43
|
+
|
|
44
|
+
export const TRIAGE_AUTOTAG_FLAG = 'surfaces.triage.autoTag';
|
|
45
|
+
|
|
46
|
+
export interface TriageTaggerOptions {
|
|
47
|
+
credentials?: DaemonCredentialStore;
|
|
48
|
+
/** Override the autotag flag lookup (used in tests). */
|
|
49
|
+
autoTagEnabled?: boolean;
|
|
50
|
+
/** Per-surface provider config; usually derived from configManager. */
|
|
51
|
+
providers?: TaggerProviderConfig;
|
|
52
|
+
/** Injectable fetch (Slack/Discord HTTP). Defaults to global fetch. */
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
/** Injectable IMAP flag-setter (used in tests to avoid a live socket). */
|
|
55
|
+
imapStoreFlag?: ImapStoreFlag;
|
|
56
|
+
/**
|
|
57
|
+
* Transient-failure retry policy for the default IMAP store implementation.
|
|
58
|
+
* Ignored when imapStoreFlag is injected and succeeds first-try.
|
|
59
|
+
*/
|
|
60
|
+
imapRetry?: ImapRetryOptions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TriageTagger {
|
|
64
|
+
/** Whether provider-side tagging is currently enabled. */
|
|
65
|
+
enabled(): boolean;
|
|
66
|
+
applyTags(request: ApplyTagsRequest): Promise<ApplyTagsResult>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readBoolFlag(
|
|
70
|
+
configManager: HandlerContext['configManager'],
|
|
71
|
+
key: string,
|
|
72
|
+
): boolean {
|
|
73
|
+
try {
|
|
74
|
+
const value = configManager.get(key as never) as unknown;
|
|
75
|
+
return value === true || value === 'true' || value === 1;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeGet(configManager: HandlerContext['configManager'], key: string): unknown {
|
|
82
|
+
try {
|
|
83
|
+
return configManager.get(key as never) as unknown;
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveProvidersFromConfig(
|
|
90
|
+
configManager: HandlerContext['configManager'],
|
|
91
|
+
): TaggerProviderConfig {
|
|
92
|
+
const out: TaggerProviderConfig = {};
|
|
93
|
+
const slackToken = safeGet(configManager, 'surfaces.slack.botToken');
|
|
94
|
+
if (typeof slackToken === 'string' && slackToken.length > 0) {
|
|
95
|
+
out.slack = { tokenConfigKey: 'surfaces.slack.botToken' };
|
|
96
|
+
}
|
|
97
|
+
const discordToken = safeGet(configManager, 'surfaces.discord.botToken');
|
|
98
|
+
if (typeof discordToken === 'string' && discordToken.length > 0) {
|
|
99
|
+
out.discord = { tokenConfigKey: 'surfaces.discord.botToken' };
|
|
100
|
+
}
|
|
101
|
+
const imapHost = safeGet(configManager, 'surfaces.email.imap.host');
|
|
102
|
+
const imapUser = safeGet(configManager, 'surfaces.email.imap.user');
|
|
103
|
+
if (typeof imapHost === 'string' && imapHost.length > 0 && typeof imapUser === 'string') {
|
|
104
|
+
const portRaw = safeGet(configManager, 'surfaces.email.imap.port');
|
|
105
|
+
const mailbox = safeGet(configManager, 'surfaces.email.imap.mailbox');
|
|
106
|
+
out.imap = {
|
|
107
|
+
host: imapHost,
|
|
108
|
+
port: typeof portRaw === 'number' ? portRaw : 993,
|
|
109
|
+
user: imapUser,
|
|
110
|
+
passwordConfigKey: 'surfaces.email.imap.password',
|
|
111
|
+
mailbox: typeof mailbox === 'string' && mailbox.length > 0 ? mailbox : 'INBOX',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveTags(request: ApplyTagsRequest): string[] {
|
|
118
|
+
if (request.tags && request.tags.length > 0) {
|
|
119
|
+
return [...new Set(request.tags.map((t) => t.trim()).filter((t) => t.length > 0))];
|
|
120
|
+
}
|
|
121
|
+
if (request.label) return [labelToTag(request.label)];
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create the triage tagger. Reads the autotag flag and provider config from the
|
|
127
|
+
* handler context; credentials are resolved lazily, per apply, from the daemon
|
|
128
|
+
* credential store.
|
|
129
|
+
*/
|
|
130
|
+
export function createTriageTagger(
|
|
131
|
+
ctx: HandlerContext,
|
|
132
|
+
options: TriageTaggerOptions = {},
|
|
133
|
+
): TriageTagger {
|
|
134
|
+
const credentials = options.credentials ?? ctx.credentials;
|
|
135
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
136
|
+
const providers = options.providers ?? resolveProvidersFromConfig(ctx.configManager);
|
|
137
|
+
// Retry wraps whichever store impl is in use (default TLS client OR an
|
|
138
|
+
// injected one), so transient failures are retried uniformly.
|
|
139
|
+
const imapStoreFlag = makeRetryingImapStoreFlag(
|
|
140
|
+
options.imapStoreFlag ?? imapStoreFlagOverTls,
|
|
141
|
+
options.imapRetry,
|
|
142
|
+
);
|
|
143
|
+
const enabled = (): boolean =>
|
|
144
|
+
options.autoTagEnabled ?? readBoolFlag(ctx.configManager, TRIAGE_AUTOTAG_FLAG);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
enabled,
|
|
148
|
+
async applyTags(request: ApplyTagsRequest): Promise<ApplyTagsResult> {
|
|
149
|
+
const { item } = request;
|
|
150
|
+
const tags = resolveTags(request);
|
|
151
|
+
const base: ApplyTagsResult = {
|
|
152
|
+
surface: item.surface,
|
|
153
|
+
itemId: item.id,
|
|
154
|
+
appliedTags: [],
|
|
155
|
+
skipped: true,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (!enabled()) {
|
|
159
|
+
return { ...base, reason: 'autotag-disabled' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Provider-side mutation is effectful — require explicit confirmation.
|
|
163
|
+
if (request.confirm !== true || request.explicitUserRequest !== true) {
|
|
164
|
+
throw new HandlerError(
|
|
165
|
+
'Provider-side triage tagging requires explicit user confirmation.',
|
|
166
|
+
REQUIRE_CONFIRM,
|
|
167
|
+
403,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
switch (item.surface) {
|
|
172
|
+
case 'email':
|
|
173
|
+
case 'imap':
|
|
174
|
+
return applyImap(item, tags, providers, credentials, imapStoreFlag, base);
|
|
175
|
+
case 'slack':
|
|
176
|
+
return applySlack(item, tags, providers, credentials, fetchImpl, ctx, base);
|
|
177
|
+
case 'discord':
|
|
178
|
+
return applyDiscord(item, tags, providers, credentials, fetchImpl, ctx, base);
|
|
179
|
+
default:
|
|
180
|
+
return { ...base, reason: `unsupported-surface:${item.surface}` };
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Triage tagger — shared types and provider-agnostic helpers.
|
|
3
|
+
//
|
|
4
|
+
// Provider config shapes, the apply request/result contract, and the tag
|
|
5
|
+
// normalization helpers used by the IMAP/Slack/Discord modules. No I/O here.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import type { InboundChannelItem, TriageLabel } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
export interface TaggerProviderConfig {
|
|
11
|
+
/** IMAP host:port (default port 993, TLS). Credentials resolved separately. */
|
|
12
|
+
imap?: { host: string; port?: number; user: string; passwordConfigKey: string; mailbox?: string };
|
|
13
|
+
/** Slack bot token config key (resolved from credential store). */
|
|
14
|
+
slack?: { tokenConfigKey: string };
|
|
15
|
+
/**
|
|
16
|
+
* Discord bot token config key (resolved from credential store), plus an
|
|
17
|
+
* optional forum-tag mapping. When `forumTagIds` maps a GoodVibes triage tag
|
|
18
|
+
* (e.g. 'GoodVibes/Spam') to a forum tag SNOWFLAKE id, items that target a
|
|
19
|
+
* forum/media-channel thread get that REAL thread tag applied (PATCH
|
|
20
|
+
* applied_tags) — exact fidelity to the contract's "Discord thread tags".
|
|
21
|
+
* Without a mapping (or for non-thread messages) tagging degrades to a
|
|
22
|
+
* unicode reaction analog.
|
|
23
|
+
*/
|
|
24
|
+
discord?: { tokenConfigKey: string; forumTagIds?: Record<string, string> };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ApplyTagsRequest {
|
|
28
|
+
item: InboundChannelItem;
|
|
29
|
+
/** Provider-side tags to apply. Defaults to [labelToTag(label)] when omitted. */
|
|
30
|
+
tags?: readonly string[];
|
|
31
|
+
label?: TriageLabel;
|
|
32
|
+
/** Must be true — provider-side mutation requires explicit confirmation. */
|
|
33
|
+
confirm?: boolean;
|
|
34
|
+
/** Mirror of the operator invocation context flag. */
|
|
35
|
+
explicitUserRequest?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ApplyTagsResult {
|
|
39
|
+
surface: string;
|
|
40
|
+
itemId: string;
|
|
41
|
+
appliedTags: string[];
|
|
42
|
+
/** True when the autotag flag is disabled or no provider matched. */
|
|
43
|
+
skipped: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** IMAP keywords cannot contain spaces or '/'; normalize the canonical tag. */
|
|
48
|
+
export function imapKeywordForTag(tag: string): string {
|
|
49
|
+
return tag.replace(/[^A-Za-z0-9_]+/g, '_');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function slackEmojiForTag(tag: string): string {
|
|
53
|
+
const lower = tag.toLowerCase();
|
|
54
|
+
if (lower.includes('spam')) return 'no_entry_sign';
|
|
55
|
+
if (lower.includes('priority')) return 'rotating_light';
|
|
56
|
+
return 'inbox_tray';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function discordEmojiForTag(tag: string): string {
|
|
60
|
+
const lower = tag.toLowerCase();
|
|
61
|
+
if (lower.includes('spam')) return '\u{1F6AB}';
|
|
62
|
+
if (lower.includes('priority')) return '\u{1F6A8}';
|
|
63
|
+
return '\u{1F4E5}';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read a non-empty string from the item's opaque metadata bag. */
|
|
67
|
+
export function stringMeta(item: InboundChannelItem, key: string): string | undefined {
|
|
68
|
+
const value = item.metadata?.[key];
|
|
69
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
70
|
+
}
|