@pellux/goodvibes-tui 0.24.1 → 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 (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/daemon/calendar/caldav-client.ts +657 -0
  5. package/src/daemon/calendar/ics.ts +556 -0
  6. package/src/daemon/calendar/index.ts +52 -0
  7. package/src/daemon/calendar/register.ts +527 -0
  8. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  9. package/src/daemon/channels/drafts/index.ts +22 -0
  10. package/src/daemon/channels/drafts/register.ts +449 -0
  11. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  12. package/src/daemon/channels/inbox/index.ts +58 -0
  13. package/src/daemon/channels/inbox/mapping.ts +190 -0
  14. package/src/daemon/channels/inbox/poller.ts +155 -0
  15. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  16. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  17. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  18. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  19. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  20. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  21. package/src/daemon/channels/inbox/register.ts +247 -0
  22. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  23. package/src/daemon/channels/routing/index.ts +39 -0
  24. package/src/daemon/channels/routing/register.ts +296 -0
  25. package/src/daemon/channels/routing/route-store.ts +278 -0
  26. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  27. package/src/daemon/email/imap-connector.ts +441 -0
  28. package/src/daemon/email/imap-parsing.ts +499 -0
  29. package/src/daemon/email/index.ts +68 -0
  30. package/src/daemon/email/register.ts +715 -0
  31. package/src/daemon/email/smtp-connector.ts +557 -0
  32. package/src/daemon/operator/credential-store.ts +129 -0
  33. package/src/daemon/operator/index.ts +43 -0
  34. package/src/daemon/operator/register-helper.ts +150 -0
  35. package/src/daemon/operator/sqlite-store.ts +124 -0
  36. package/src/daemon/operator/surfaces.ts +137 -0
  37. package/src/daemon/operator/types.ts +207 -0
  38. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  39. package/src/daemon/remote/backends/docker.ts +80 -0
  40. package/src/daemon/remote/backends/index.ts +34 -0
  41. package/src/daemon/remote/backends/local-process.ts +113 -0
  42. package/src/daemon/remote/backends/process-runner.ts +151 -0
  43. package/src/daemon/remote/backends/ssh.ts +120 -0
  44. package/src/daemon/remote/backends/types.ts +71 -0
  45. package/src/daemon/remote/dispatcher.ts +160 -0
  46. package/src/daemon/remote/index.ts +74 -0
  47. package/src/daemon/remote/peer-registry.ts +321 -0
  48. package/src/daemon/remote/register.ts +411 -0
  49. package/src/daemon/triage/index.ts +59 -0
  50. package/src/daemon/triage/integration.ts +179 -0
  51. package/src/daemon/triage/pipeline.ts +285 -0
  52. package/src/daemon/triage/register.ts +231 -0
  53. package/src/daemon/triage/scorer.ts +287 -0
  54. package/src/daemon/triage/tagger.ts +777 -0
  55. package/src/runtime/services.ts +35 -0
  56. package/src/version.ts +1 -1
@@ -0,0 +1,298 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Persistent cursor + item store for the inbound feed.
3
+ //
4
+ // Backed by OperatorSqliteStore (sql.js WASM) at
5
+ // {wd}/.goodvibes/tui/operator/inbox.sqlite
6
+ //
7
+ // Two tables:
8
+ // items(id PK, provider, kind, fromDigest, subjectPreview, bodyPreview,
9
+ // routeId, receivedAt INT, unread INT, triageScore REAL NULL,
10
+ // triageTags TEXT NULL)
11
+ // cursors(provider PK, nextSince INT)
12
+ //
13
+ // Dedup is by items.id (INSERT OR IGNORE / upsert). nextSince advances
14
+ // monotonically per provider = max(receivedAt) ever seen. triageScore/
15
+ // triageTags columns are written by the triage surface; we only READ them.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ import { OperatorSqliteStore } from '../../operator/index.ts';
19
+ import type { InboundChannelItem } from './provider-adapter.ts';
20
+
21
+ const SCHEMA: string[] = [
22
+ `CREATE TABLE IF NOT EXISTS items (
23
+ id TEXT PRIMARY KEY,
24
+ provider TEXT NOT NULL,
25
+ kind TEXT NOT NULL,
26
+ fromDigest TEXT NOT NULL,
27
+ subjectPreview TEXT NOT NULL,
28
+ bodyPreview TEXT NOT NULL,
29
+ routeId TEXT,
30
+ receivedAt INTEGER NOT NULL,
31
+ unread INTEGER NOT NULL,
32
+ triageScore REAL,
33
+ triageTags TEXT
34
+ )`,
35
+ `CREATE INDEX IF NOT EXISTS idx_items_provider_received
36
+ ON items(provider, receivedAt)`,
37
+ `CREATE INDEX IF NOT EXISTS idx_items_received
38
+ ON items(receivedAt)`,
39
+ `CREATE TABLE IF NOT EXISTS cursors (
40
+ provider TEXT PRIMARY KEY,
41
+ nextSince INTEGER NOT NULL
42
+ )`,
43
+ ];
44
+
45
+ interface ItemRow {
46
+ id: string;
47
+ provider: string;
48
+ kind: string;
49
+ fromDigest: string;
50
+ subjectPreview: string;
51
+ bodyPreview: string;
52
+ routeId: string | null;
53
+ receivedAt: number;
54
+ unread: number;
55
+ triageScore: number | null;
56
+ triageTags: string | null;
57
+ }
58
+
59
+ export interface InboxQuery {
60
+ providers?: readonly string[];
61
+ since?: number;
62
+ /** Max items returned across the whole query. */
63
+ limit: number;
64
+ }
65
+
66
+ export class InboxCursorStore {
67
+ private readonly store: OperatorSqliteStore;
68
+ private dirty = false;
69
+
70
+ constructor(workingDirectory: string, fileName = 'inbox.sqlite') {
71
+ this.store = new OperatorSqliteStore({
72
+ workingDirectory,
73
+ fileName,
74
+ schema: SCHEMA,
75
+ });
76
+ }
77
+
78
+ get dbPath(): string {
79
+ return this.store.dbPath;
80
+ }
81
+
82
+ async init(): Promise<void> {
83
+ await this.store.init();
84
+ }
85
+
86
+ /**
87
+ * Insert/refresh items, deduping by id. Existing rows keep their triage
88
+ * columns (written by the triage surface) and are NOT clobbered — only the
89
+ * mutable feed fields (unread, previews, routeId, receivedAt) are updated.
90
+ * Returns the number of NEW (previously unseen) items.
91
+ */
92
+ upsertItems(items: readonly InboundChannelItem[]): number {
93
+ if (items.length === 0) return 0;
94
+ let inserted = 0;
95
+ const existing = new Set(
96
+ this.store
97
+ .all<{ id: string }>('SELECT id FROM items')
98
+ .map((r) => r.id),
99
+ );
100
+ this.store.transaction(() => {
101
+ for (const item of items) {
102
+ const isNew = !existing.has(item.id);
103
+ if (isNew) inserted += 1;
104
+ // Upsert preserving triage columns: ON CONFLICT updates feed fields only.
105
+ this.store.run(
106
+ `INSERT INTO items
107
+ (id, provider, kind, fromDigest, subjectPreview, bodyPreview,
108
+ routeId, receivedAt, unread, triageScore, triageTags)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
110
+ ON CONFLICT(id) DO UPDATE SET
111
+ provider = excluded.provider,
112
+ kind = excluded.kind,
113
+ fromDigest = excluded.fromDigest,
114
+ subjectPreview = excluded.subjectPreview,
115
+ bodyPreview = excluded.bodyPreview,
116
+ routeId = COALESCE(excluded.routeId, items.routeId),
117
+ receivedAt = excluded.receivedAt,
118
+ unread = excluded.unread`,
119
+ [
120
+ item.id,
121
+ item.provider,
122
+ item.kind,
123
+ item.fromDigest,
124
+ item.subjectPreview,
125
+ item.bodyPreview,
126
+ item.routeId ?? null,
127
+ item.receivedAt,
128
+ item.unread ? 1 : 0,
129
+ ],
130
+ );
131
+ }
132
+ });
133
+ if (inserted > 0 || items.length > 0) this.dirty = true;
134
+ return inserted;
135
+ }
136
+
137
+ /**
138
+ * Advance a provider's cursor monotonically. The stored value is always the
139
+ * max of the current value and the supplied candidate.
140
+ */
141
+ advanceCursor(provider: string, candidate: number): void {
142
+ if (!Number.isFinite(candidate)) return;
143
+ const current = this.getCursor(provider);
144
+ const next = Math.max(current, Math.floor(candidate));
145
+ if (next === current && current !== 0) return;
146
+ this.store.run(
147
+ `INSERT INTO cursors (provider, nextSince) VALUES (?, ?)
148
+ ON CONFLICT(provider) DO UPDATE SET
149
+ nextSince = MAX(cursors.nextSince, excluded.nextSince)`,
150
+ [provider, next],
151
+ );
152
+ this.dirty = true;
153
+ }
154
+
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
+ /** Current cursor for a provider (0 when unset). */
170
+ getCursor(provider: string): number {
171
+ const row = this.store.get<{ nextSince: number }>(
172
+ 'SELECT nextSince FROM cursors WHERE provider = ?',
173
+ [provider],
174
+ );
175
+ return row ? Number(row.nextSince) : 0;
176
+ }
177
+
178
+ /**
179
+ * Read items for the feed, filtered by provider set + since, newest first,
180
+ * capped at limit. Maps SQLite rows back to the wire shape.
181
+ */
182
+ listItems(query: InboxQuery): InboundChannelItem[] {
183
+ const clauses: string[] = [];
184
+ const params: (string | number)[] = [];
185
+ if (query.providers && query.providers.length > 0) {
186
+ const placeholders = query.providers.map(() => '?').join(', ');
187
+ clauses.push(`provider IN (${placeholders})`);
188
+ params.push(...query.providers);
189
+ }
190
+ if (typeof query.since === 'number' && Number.isFinite(query.since)) {
191
+ clauses.push('receivedAt > ?');
192
+ params.push(Math.floor(query.since));
193
+ }
194
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
195
+ const limit = Math.max(0, Math.floor(query.limit));
196
+ params.push(limit);
197
+ const rows = this.store.all<ItemRow>(
198
+ `SELECT id, provider, kind, fromDigest, subjectPreview, bodyPreview,
199
+ routeId, receivedAt, unread, triageScore, triageTags
200
+ FROM items ${where}
201
+ ORDER BY receivedAt DESC, id ASC
202
+ LIMIT ?`,
203
+ params,
204
+ );
205
+ return rows.map(rowToItem);
206
+ }
207
+
208
+ /** Highest receivedAt across the (optionally provider-filtered) feed, or 0. */
209
+ maxReceivedAt(providers?: readonly string[]): number {
210
+ let where = '';
211
+ const params: (string | number)[] = [];
212
+ if (providers && providers.length > 0) {
213
+ const placeholders = providers.map(() => '?').join(', ');
214
+ where = `WHERE provider IN (${placeholders})`;
215
+ params.push(...providers);
216
+ }
217
+ const row = this.store.get<{ maxReceived: number | null }>(
218
+ `SELECT MAX(receivedAt) AS maxReceived FROM items ${where}`,
219
+ params,
220
+ );
221
+ return row && row.maxReceived != null ? Number(row.maxReceived) : 0;
222
+ }
223
+
224
+ /** Count of items per provider for the (optional) provider set. */
225
+ countByProvider(providers?: readonly string[]): Map<string, number> {
226
+ let where = '';
227
+ const params: (string | number)[] = [];
228
+ if (providers && providers.length > 0) {
229
+ const placeholders = providers.map(() => '?').join(', ');
230
+ where = `WHERE provider IN (${placeholders})`;
231
+ params.push(...providers);
232
+ }
233
+ const rows = this.store.all<{ provider: string; n: number }>(
234
+ `SELECT provider, COUNT(*) AS n FROM items ${where} GROUP BY provider`,
235
+ params,
236
+ );
237
+ const out = new Map<string, number>();
238
+ for (const row of rows) out.set(row.provider, Number(row.n));
239
+ return out;
240
+ }
241
+
242
+ /** Persist to disk if anything changed since the last flush. */
243
+ async flush(): Promise<void> {
244
+ if (!this.dirty) return;
245
+ await this.store.save();
246
+ this.dirty = false;
247
+ }
248
+
249
+ /** Flush (best-effort) then close the underlying database. */
250
+ async close(): Promise<void> {
251
+ try {
252
+ await this.flush();
253
+ } finally {
254
+ this.store.close();
255
+ }
256
+ }
257
+ }
258
+
259
+ function rowToItem(row: ItemRow): InboundChannelItem {
260
+ const item: InboundChannelItem = {
261
+ id: row.id,
262
+ provider: row.provider,
263
+ kind: normalizeKind(row.kind),
264
+ fromDigest: row.fromDigest,
265
+ subjectPreview: row.subjectPreview,
266
+ bodyPreview: row.bodyPreview,
267
+ receivedAt: Number(row.receivedAt),
268
+ unread: Number(row.unread) !== 0,
269
+ };
270
+ 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
+ return item;
277
+ }
278
+
279
+ function normalizeKind(value: string): InboundChannelItem['kind'] {
280
+ return value === 'dm' || value === 'thread' || value === 'mention' || value === 'reaction'
281
+ ? value
282
+ : 'dm';
283
+ }
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
+ }
@@ -0,0 +1,58 @@
1
+ // Public barrel for the inbound inbox surface.
2
+ //
3
+ // Integration wires this surface the same way as every other daemon surface
4
+ // (e.g. routing/triage): it calls the `register(ctx): Unregister` entry point
5
+ // from its surface bootstrap and disposes the returned Unregister on shutdown.
6
+ // `register` is the SurfaceRegister-shaped wrapper around registerInboxMethods.
7
+
8
+ export { register, registerInboxMethods, INBOX_LIST_METHOD_ID, INBOX_LIST_SCOPES } from './register.ts';
9
+ export type {
10
+ InboxListInput,
11
+ InboxListOutput,
12
+ InboxProviderReport,
13
+ RegisterInboxOptions,
14
+ } from './register.ts';
15
+
16
+ export type {
17
+ InboundChannelItem,
18
+ InboundProviderAdapter,
19
+ ProviderPollOptions,
20
+ ProviderPollResult,
21
+ ProviderState,
22
+ AdapterContext,
23
+ AdapterFactory,
24
+ RouteResolver,
25
+ } from './provider-adapter.ts';
26
+ export {
27
+ POLL_CADENCE_MS,
28
+ registerAdapterFactory,
29
+ registeredProviderIds,
30
+ buildAdapters,
31
+ clearAdapterRegistry,
32
+ } from './provider-adapter.ts';
33
+
34
+ export { InboxCursorStore } from './cursor-store.ts';
35
+ export type { InboxQuery } from './cursor-store.ts';
36
+ export { InboundPoller } from './poller.ts';
37
+ export type { ProviderStatus, PollerOptions } from './poller.ts';
38
+
39
+ export { createSlackAdapter } from './providers/slack.ts';
40
+ export { createDiscordAdapter } from './providers/discord.ts';
41
+ export { createEmailAdapter } from './providers/email.ts';
42
+ export {
43
+ ImapClient,
44
+ parseFetchResponse,
45
+ decodeHeader,
46
+ imapDate,
47
+ } from './providers/imap-client.ts';
48
+ export type { ImapConfig, ImapEnvelope } from './providers/imap-client.ts';
49
+
50
+ export {
51
+ digestSender,
52
+ stripPii,
53
+ stripMarkup,
54
+ toSubjectPreview,
55
+ toBodyPreview,
56
+ SUBJECT_PREVIEW_MAX,
57
+ BODY_PREVIEW_MAX,
58
+ } from './mapping.ts';
@@ -0,0 +1,190 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Pure mapping + redaction helpers shared by every adapter.
3
+ //
4
+ // Rules (from the handoff contract):
5
+ // - fromDigest = sha256First(senderExternalId, 16) (never the raw id)
6
+ // (16 hex chars == the first 8 bytes of the SHA-256 digest;
7
+ // handoff acceptance checklist: 'first 16 hex chars')
8
+ // - bodyPreview = plain-text, PII-stripped, truncated to 500 chars
9
+ // - subjectPreview <= 200 chars
10
+ // These are pure and deterministic so they are unit-testable in isolation.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { sha256First } from '../../operator/index.ts';
14
+
15
+ export const SUBJECT_PREVIEW_MAX = 200;
16
+ export const BODY_PREVIEW_MAX = 500;
17
+
18
+ /**
19
+ * Digest a sender's external id to a stable 16-hex-char token (the first 8
20
+ * bytes of the SHA-256 digest). The handoff wire-shape note's 'SHA-256 first-8'
21
+ * refers to those 8 bytes; the acceptance checklist states it as 'first 16 hex
22
+ * chars'. Both describe the same value emitted here.
23
+ */
24
+ export function digestSender(senderExternalId: string): string {
25
+ return sha256First(senderExternalId, 16);
26
+ }
27
+
28
+ // PII patterns stripped from body previews before they ever leave the daemon.
29
+ const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
30
+ // E.164-ish and common separated phone numbers (>=7 digits with separators).
31
+ const PHONE_RE = /(?:\+?\d[\d\s().-]{6,}\d)/g;
32
+ // Long digit runs that look like card / account numbers (13-19 digits).
33
+ const LONG_NUMBER_RE = /\b\d{13,19}\b/g;
34
+ // IPv4 addresses.
35
+ const IPV4_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
36
+
37
+ // --- Secret / token patterns -----------------------------------------------
38
+ // These must run BEFORE the generic numeric/email scrubbers and are ordered
39
+ // most-specific first so a long opaque secret is never partially redacted.
40
+ //
41
+ // `Authorization: Bearer <token>` / bare `Bearer <token>`.
42
+ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi;
43
+ // JSON Web Tokens: three base64url segments separated by dots (header.payload.sig).
44
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g;
45
+ // Slack tokens: xoxb-/xoxp-/xoxa-/xoxr-/xoxs-... and legacy xox*-.
46
+ const SLACK_TOKEN_RE = /\bxox[abeoprs]-[A-Za-z0-9-]{8,}/gi;
47
+ // Common prefixed provider keys: OpenAI sk-/sk-proj-, GitHub gh[poursa]_,
48
+ // Google AIza..., Stripe sk_live_/pk_live_/rk_live_, Slack-app xapp-, AWS AKIA...
49
+ const PREFIXED_KEY_RE =
50
+ /\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{16,}|gh[poursa]_[A-Za-z0-9]{16,}|AIza[A-Za-z0-9_-]{16,}|(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|xapp-[A-Za-z0-9-]{8,}|AKIA[A-Z0-9]{16})/g;
51
+ // `token=`, `api_key=`, `access_token: ...`, `secret = ...` style key/value pairs.
52
+ const KV_SECRET_RE =
53
+ /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|pwd|authorization|auth)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[A-Za-z0-9._~+/=-]{6,})/gi;
54
+ // Generic high-entropy opaque blobs (>=24 chars of base64url/hex) not already
55
+ // caught above — catches raw API keys pasted without a recognizable prefix.
56
+ const OPAQUE_SECRET_RE = /\b[A-Za-z0-9_-]{24,}\b/g;
57
+
58
+ /**
59
+ * Replace PII and credentials with stable redaction tokens (does not alter the
60
+ * length budget). Secrets/tokens are scrubbed before the email/phone/number
61
+ * passes so an OAuth/bearer/API token can never leak into a preview.
62
+ */
63
+ export function stripPii(input: string): string {
64
+ return input
65
+ .replace(BEARER_RE, '[token]')
66
+ .replace(JWT_RE, '[token]')
67
+ .replace(SLACK_TOKEN_RE, '[token]')
68
+ .replace(PREFIXED_KEY_RE, '[token]')
69
+ .replace(KV_SECRET_RE, (match) => {
70
+ const sep = match.includes('=') ? '=' : ':';
71
+ const key = match.slice(0, match.indexOf(sep)).trimEnd();
72
+ return `${key}${sep}[token]`;
73
+ })
74
+ .replace(EMAIL_RE, '[email]')
75
+ .replace(IPV4_RE, '[ip]')
76
+ .replace(LONG_NUMBER_RE, '[number]')
77
+ .replace(PHONE_RE, (match) => {
78
+ // Avoid eating short numeric tokens that survived LONG_NUMBER_RE; only
79
+ // redact when there are at least 7 digits.
80
+ const digits = match.replace(/\D/g, '');
81
+ return digits.length >= 7 ? '[phone]' : match;
82
+ })
83
+ // Sweep any remaining long opaque blob (e.g. a bare API key) last so we do
84
+ // not clobber the redaction tokens we just inserted.
85
+ .replace(OPAQUE_SECRET_RE, (match) => (/^[A-Za-z]+$/.test(match) ? match : '[token]'));
86
+ }
87
+
88
+ // --- Markup / MIME de-structuring ------------------------------------------
89
+ // Email BODY[TEXT] is frequently raw HTML and/or a multipart/MIME payload with
90
+ // boundary lines and Content-* headers. Previews must be human-readable plain
91
+ // text, so we de-MIME (prefer the text/plain part), strip tags, and decode the
92
+ // handful of HTML entities that survive into previews. This is a no-op on text
93
+ // that is already plain.
94
+ const HTML_ENTITIES: Record<string, string> = {
95
+ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"',
96
+ '&#39;': "'", '&apos;': "'", '&nbsp;': ' ',
97
+ };
98
+
99
+ /** True when the text looks like a MIME multipart payload (has boundary lines). */
100
+ function isMultipart(text: string): boolean {
101
+ return /^--[^\r\n]+\r?\n/m.test(text) && /content-type:/i.test(text);
102
+ }
103
+
104
+ /**
105
+ * Given a multipart body, return the decoded text/plain part if present,
106
+ * otherwise the text/html part, otherwise the original input. Strips the
107
+ * per-part MIME headers so only the body bytes remain.
108
+ */
109
+ function extractMimePart(text: string): string {
110
+ const boundaryMatch = /^--([^\r\n]+?)(?:--)?\r?$/m.exec(text);
111
+ if (!boundaryMatch) return text;
112
+ const boundary = boundaryMatch[1]!;
113
+ const parts = text.split(new RegExp(`--${boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:--)?\r?\n?`));
114
+ let htmlPart: string | undefined;
115
+ for (const rawPart of parts) {
116
+ const split = /\r?\n\r?\n/.exec(rawPart);
117
+ if (!split) continue;
118
+ const headers = rawPart.slice(0, split.index);
119
+ const body = rawPart.slice(split.index + split[0].length);
120
+ const ctype = /content-type:\s*([^\r\n;]+)/i.exec(headers)?.[1]?.trim().toLowerCase();
121
+ if (!ctype) continue;
122
+ if (ctype === 'text/plain') return body.trim();
123
+ if (ctype === 'text/html' && htmlPart === undefined) htmlPart = body;
124
+ }
125
+ return (htmlPart ?? text).trim();
126
+ }
127
+
128
+ /** Strip stray top-level MIME/Content-* headers from a single-part body. */
129
+ function stripMimeHeaders(text: string): string {
130
+ return text.replace(
131
+ /^(?:content-type|content-transfer-encoding|content-disposition|content-id|mime-version|--[^\r\n]+)\b[^\r\n]*\r?\n/gim,
132
+ '',
133
+ );
134
+ }
135
+
136
+ /** Decode the small set of HTML entities that matter for plain-text previews. */
137
+ function decodeEntities(text: string): string {
138
+ return text
139
+ .replace(/&amp;|&lt;|&gt;|&quot;|&#39;|&apos;|&nbsp;/gi, (m) => HTML_ENTITIES[m.toLowerCase()] ?? m)
140
+ .replace(/&#(\d{1,7});/g, (_m, dec: string) => {
141
+ const code = Number.parseInt(dec, 10);
142
+ return Number.isFinite(code) ? String.fromCodePoint(code) : _m;
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Convert HTML/MIME body text into readable plain text. Drops <script>/<style>
148
+ * blocks entirely, turns block-level tags into spaces, removes all remaining
149
+ * tags, decodes entities, and de-MIMEs multipart payloads. Plain text passes
150
+ * through unchanged (modulo entity decoding).
151
+ */
152
+ export function stripMarkup(input: string): string {
153
+ let text = input;
154
+ if (isMultipart(text)) {
155
+ text = extractMimePart(text);
156
+ }
157
+ text = stripMimeHeaders(text);
158
+ // Drop script/style contents before tag removal so their bodies never leak.
159
+ text = text.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ');
160
+ // Only treat as HTML if there is an actual tag; avoids mangling plain text
161
+ // that merely contains a stray '<'.
162
+ if (/<[a-z!/][^>]*>/i.test(text)) {
163
+ text = text
164
+ .replace(/<\/?(?:br|p|div|tr|li|h[1-6]|table|ul|ol|blockquote|hr)\b[^>]*>/gi, ' ')
165
+ .replace(/<[^>]+>/g, '');
166
+ }
167
+ return decodeEntities(text);
168
+ }
169
+
170
+ /** Collapse whitespace and trim — keeps previews single-line and tidy. */
171
+ export function normalizeWhitespace(input: string): string {
172
+ return input.replace(/\s+/g, ' ').trim();
173
+ }
174
+
175
+ /** Build a display-safe subject preview (<=200 chars, no PII, single line). */
176
+ export function toSubjectPreview(raw: string | undefined | null): string {
177
+ const normalized = normalizeWhitespace(stripPii(raw ?? ''));
178
+ return normalized.slice(0, SUBJECT_PREVIEW_MAX);
179
+ }
180
+
181
+ /**
182
+ * Build a display-safe body preview (<=500 chars, plain text, PII-stripped,
183
+ * single line). HTML/MIME is de-structured to readable text first so previews
184
+ * of real-world (HTML/multipart) emails never leak tags or MIME headers.
185
+ */
186
+ export function toBodyPreview(raw: string | undefined | null): string {
187
+ const plain = stripMarkup(raw ?? '');
188
+ const normalized = normalizeWhitespace(stripPii(plain));
189
+ return normalized.slice(0, BODY_PREVIEW_MAX);
190
+ }