clearhand 0.1.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 (38) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +146 -0
  3. package/SECURITY.md +77 -0
  4. package/bin/clearhand.js +230 -0
  5. package/dist/mcp/index.js +159 -0
  6. package/dist/server/connectors/crawl.js +52 -0
  7. package/dist/server/connectors/github.js +45 -0
  8. package/dist/server/connectors/mailbox.js +257 -0
  9. package/dist/server/connectors/stripe.js +252 -0
  10. package/dist/server/context.js +83 -0
  11. package/dist/server/db/index.js +324 -0
  12. package/dist/server/demo.js +194 -0
  13. package/dist/server/index.js +51 -0
  14. package/dist/server/routes/api.js +704 -0
  15. package/dist/server/secrets/redaction.js +62 -0
  16. package/dist/server/secrets/store.js +238 -0
  17. package/dist/server/services/approval.js +206 -0
  18. package/dist/server/services/connections.js +99 -0
  19. package/dist/server/services/executor.js +417 -0
  20. package/dist/server/services/lint.js +129 -0
  21. package/dist/server/services/onboarding.js +168 -0
  22. package/dist/server/services/overlay.js +65 -0
  23. package/dist/server/trust/egress.js +12 -0
  24. package/dist/shared/actions.js +136 -0
  25. package/dist/shared/config.js +33 -0
  26. package/dist/shared/paths.js +34 -0
  27. package/package.json +73 -0
  28. package/public/assets/index-BicJ8AKo.css +1 -0
  29. package/public/assets/index-DZSNZW8-.js +43 -0
  30. package/public/index.html +17 -0
  31. package/templates/CLEARHAND.md +79 -0
  32. package/templates/overlay/escalation.md +21 -0
  33. package/templates/overlay/policies.md +30 -0
  34. package/templates/overlay/products.md +25 -0
  35. package/templates/overlay/tone-lint.json +14 -0
  36. package/templates/overlay/voice.md +24 -0
  37. package/templates/skills/clearhand-cs/SKILL.md +75 -0
  38. package/templates/skills/clearhand-onboard/SKILL.md +126 -0
@@ -0,0 +1,159 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ /**
5
+ * clearhand-mcp: a thin, deterministic MCP server exposing the local ClearHand
6
+ * HTTP API as typed tools. ZERO business logic here — every guard (lint,
7
+ * freshness, CAS, proration, resolve-guard, redaction) lives server-side, so
8
+ * a prompt-injected agent gains nothing by calling these creatively.
9
+ *
10
+ * Non-2xx responses are surfaced verbatim so the agent can react to a 409
11
+ * ("pending_financial_actions") by executing the refund first.
12
+ *
13
+ * Env: CLEARHAND_BASE_URL (default http://localhost:3117),
14
+ * CLEARHAND_EXECUTE_TOKEN (forwarded as bearer ONLY by the execute tool).
15
+ */
16
+ const BASE = process.env.CLEARHAND_BASE_URL ?? 'http://localhost:3117';
17
+ async function call(method, path, body, token) {
18
+ try {
19
+ const res = await fetch(`${BASE}${path}`, {
20
+ method,
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
24
+ },
25
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
26
+ });
27
+ const text = await res.text();
28
+ if (!res.ok) {
29
+ return { content: [{ type: 'text', text: `HTTP ${res.status}: ${text}` }], isError: true };
30
+ }
31
+ return { content: [{ type: 'text', text }] };
32
+ }
33
+ catch (err) {
34
+ return {
35
+ content: [
36
+ {
37
+ type: 'text',
38
+ text: `Could not reach the ClearHand server at ${BASE} — is it running? (clearhand start) ${String(err)}`,
39
+ },
40
+ ],
41
+ isError: true,
42
+ };
43
+ }
44
+ }
45
+ const server = new McpServer({ name: 'clearhand', version: '0.1.0' });
46
+ server.tool('clearhand_status', 'Connection/capability map (metadata only, never secret values), overlay files, and queue counts. Call this first to learn what enrichment is possible. If a connector is missing, send the USER to its connect_url — never ask for credentials in chat.', {}, async () => call('GET', '/api/status'));
47
+ server.tool('clearhand_sync_inbox', 'Fetch new mail from the connected inbox into cases. Returns scanned/ingested/newCases counts.', {}, async () => call('POST', '/api/sync'));
48
+ server.tool('clearhand_list_cases', 'List support cases. status: new|processed|reviewing|pending|resolved|escalated|ignored. search covers subjects, customer emails, summaries AND message bodies. sort: updated (default) | oldest (work a backlog front-to-back) | money_first (cases owing refunds/cancels surface first). date_from/date_to (ISO) and offset page through large backlogs.', {
49
+ status: z.string().optional(),
50
+ search: z.string().optional(),
51
+ sort: z.enum(['updated', 'oldest', 'money_first']).optional(),
52
+ date_from: z.string().optional(),
53
+ date_to: z.string().optional(),
54
+ limit: z.number().int().max(200).optional(),
55
+ offset: z.number().int().optional(),
56
+ }, async (args) => {
57
+ const q = new URLSearchParams();
58
+ for (const [k, v] of Object.entries(args))
59
+ if (v !== undefined)
60
+ q.set(k, String(v));
61
+ return call('GET', `/api/cases?${q}`);
62
+ });
63
+ server.tool('clearhand_get_case', 'Full case detail: conversation, current draft, proposed actions, enrichment (prepared_context).', { case_id: z.number().int() }, async (args) => call('GET', `/api/cases/${args.case_id}`));
64
+ server.tool('clearhand_enrich_case', 'Run server-side Stripe enrichment for the case customer (LTV, subscriptions, charges, refund history). The stripe context slice is server-owned and trustworthy.', { case_id: z.number().int() }, async (args) => call('POST', `/api/cases/${args.case_id}/enrich`));
65
+ server.tool('clearhand_update_case', 'Set triage fields on a case: category, priority (low|normal|high|urgent), summary, language.', {
66
+ case_id: z.number().int(),
67
+ category: z.string().optional(),
68
+ priority: z.string().optional(),
69
+ summary: z.string().optional(),
70
+ language: z.string().optional(),
71
+ }, async ({ case_id, ...rest }) => call('PATCH', `/api/cases/${case_id}`, rest));
72
+ server.tool('clearhand_patch_context', 'Attach qualitative context slices to a case: customer_history, site_facts, notes, bug_repro. Stripe-shaped data is rejected — the money slice is server-owned.', { case_id: z.number().int(), context: z.record(z.unknown()) }, async (args) => call('PATCH', `/api/cases/${args.case_id}/context`, args.context));
73
+ server.tool('clearhand_update_draft', 'Create/replace the current reply draft for a case. Returns tone-lint warnings from the overlay config.', {
74
+ case_id: z.number().int(),
75
+ body_english: z.string(),
76
+ body_translated: z.string().optional(),
77
+ target_language: z.string().optional(),
78
+ }, async ({ case_id, ...rest }) => call('POST', `/api/cases/${case_id}/update-draft`, { ...rest, created_by: 'agent' }));
79
+ server.tool('clearhand_propose_action', 'Propose a typed action (Action Proposal v1). Types: refund (amount_cents!), cancel_subscription (cancel_mode: immediate|end_of_period|scheduled), send_reply (body_text = the real body), escalate (reason), set_case_status (target_status: resolved|pending|ignored), create_github_issue (title, body). Proposals are linted and NEVER executed without human approval. Money moves earlier in execution_order by default.', {
80
+ case_id: z.number().int(),
81
+ action_type: z.enum([
82
+ 'refund',
83
+ 'cancel_subscription',
84
+ 'send_reply',
85
+ 'escalate',
86
+ 'set_case_status',
87
+ 'create_github_issue',
88
+ ]),
89
+ proposal: z.record(z.unknown()),
90
+ execution_order: z.number().int().optional(),
91
+ confidence: z.number().min(0).max(1).optional(),
92
+ policy_matched: z.string().optional(),
93
+ }, async ({ case_id, ...rest }) => call('POST', `/api/cases/${case_id}/actions`, rest));
94
+ server.tool('clearhand_execute_actions', 'THE ONLY MONEY PATH. Approve and execute previously proposed actions, in execution order, through the deterministic executor (CAS claim, confirm-before-success, proration + resolve guards). Requires the human-held CLEARHAND_EXECUTE_TOKEN when configured — by default, direct the user to the dashboard Approve button instead of calling this.', {
95
+ case_id: z.number().int(),
96
+ action_ids: z.array(z.number().int()).min(1),
97
+ send_mode: z.enum(['translated', 'english', 'both']).optional(),
98
+ allow_pending_financial: z.boolean().optional(),
99
+ }, async ({ case_id, action_ids, send_mode, allow_pending_financial }) => call('POST', `/api/cases/${case_id}/actions/execute`, { actionIds: action_ids, sendMode: send_mode, allow_pending_financial }, process.env.CLEARHAND_EXECUTE_TOKEN));
100
+ server.tool('clearhand_send_reply', 'Send a reply directly (marks pending send_reply proposals executed; never executes financial actions). Returns 409 if a refund/cancel is still un-executed on the case — execute those first.', {
101
+ case_id: z.number().int(),
102
+ body: z.string(),
103
+ allow_pending_financial: z.boolean().optional(),
104
+ }, async ({ case_id, ...rest }) => call('POST', `/api/cases/${case_id}/send-reply`, rest));
105
+ server.tool('clearhand_set_case_status', 'Change case status. Setting "resolved" is blocked with 409 while financial actions are un-executed (the resolve-guard).', {
106
+ case_id: z.number().int(),
107
+ status: z.enum(['new', 'processed', 'reviewing', 'pending', 'resolved', 'escalated', 'ignored']),
108
+ allow_pending_financial: z.boolean().optional(),
109
+ }, async ({ case_id, ...rest }) => call('POST', `/api/cases/${case_id}/status`, rest));
110
+ server.tool('clearhand_extract_sent_samples', 'ONBOARDING: read recent sent mail (locally, transiently) to learn the user’s voice, de-facto policies, and FAQ answers. Synthesize the results into .clearhand/overlay/*.md files.', { limit: z.number().int().max(500).optional(), since_days: z.number().int().max(365).optional() }, async (args) => call('POST', '/api/extract/sent-samples', { limit: args.limit, sinceDays: args.since_days }));
111
+ server.tool('clearhand_extract_stripe_summary', 'ONBOARDING: product/price catalog plus actual refund practice stats from Stripe, for evidence-based policy questions.', {}, async () => call('POST', '/api/extract/stripe-summary'));
112
+ server.tool('clearhand_crawl_site', 'ONBOARDING: fetch the user’s own public pages (pricing, ToS, refund policy, FAQ) as text for overlay synthesis.', { urls: z.array(z.string()).min(1).max(20) }, async (args) => call('POST', '/api/extract/crawl', { urls: args.urls }));
113
+ server.tool('clearhand_save_calibration', 'ONBOARDING: save a shadow-draft calibration pair (your draft vs what the user actually sent) for review on the dashboard Calibration screen.', {
114
+ customer_message: z.string(),
115
+ clearhand_draft: z.string(),
116
+ actual_reply: z.string().optional(),
117
+ context_note: z.string().optional(),
118
+ }, async (args) => call('POST', '/api/calibrations', args));
119
+ server.tool('clearhand_get_feedback', 'LEARNING LOOP: read recent human corrections — draft edits (original vs modified), action modifications with per-key diffs, rejections, supersessions, calibration verdicts. Review these at session end; a correction pattern that repeats is a rule waiting to be written.', {}, async () => call('GET', '/api/feedback'));
120
+ server.tool('clearhand_get_calibrations', 'LEARNING LOOP: read shadow-draft calibrations including the user’s verdicts (fine | edited | wrong_policy | wrong_facts | wrong_tone) and notes. Fold every wrong_* verdict back into the overlay.', {}, async () => call('GET', '/api/calibrations'));
121
+ server.tool('clearhand_propose_learning_review', 'LEARNING LOOP: propose a rule change distilled from repeated corrections. The user approves or rejects it on the dashboard (Activity page) — the human gate. proposed_rule: the exact rule text; target_file: which overlay file it belongs in (voice.md | policies.md | products.md | escalation.md | tone-lint.json).', {
122
+ case_id: z.number().int().optional(),
123
+ category: z.string().optional(),
124
+ proposed_rule: z.string(),
125
+ target_file: z.enum(['voice.md', 'policies.md', 'products.md', 'escalation.md', 'tone-lint.json']),
126
+ target_section: z.string().optional(),
127
+ }, async (args) => call('POST', '/api/learning-reviews', args));
128
+ server.tool('clearhand_get_learning_reviews', 'LEARNING LOOP: list rule-change reviews and their states (proposed → human approves/rejects → you apply). Apply every APPROVED review to its overlay file, then mark it applied.', {}, async () => call('GET', '/api/learning-reviews'));
129
+ server.tool('clearhand_mark_review_applied', 'LEARNING LOOP: mark an APPROVED learning review as applied — call this only AFTER the overlay edit actually landed on disk. Approve/reject themselves are human-only (dashboard).', { review_id: z.number().int() }, async ({ review_id }) => call('POST', `/api/learning-reviews/${review_id}/resolve`, { status: 'applied' }));
130
+ server.tool('clearhand_onboarding_progress', 'ONBOARDING: drive the GUI while you work. Advance a stage (status: active|done|skipped) and/or emit a progress event the user watches live. Event kinds: activity {line} (one-line present tense, replaced in place), finding {track, chip|card...} (evidence with counts — receipts, not theater), conflict {question, options}, artifact {name, bytes}, batch_case {group, title, note}, batch_done {processed, drafts, money, escalated, ignorable}. Emit events as the work actually happens; never simulate.', {
131
+ stage: z.enum([
132
+ 'arrival',
133
+ 'mailbox',
134
+ 'stripe',
135
+ 'reading_products',
136
+ 'reading_voice',
137
+ 'reading_policies',
138
+ 'style',
139
+ 'calibration',
140
+ 'first_batch',
141
+ 'live',
142
+ ]),
143
+ status: z.enum(['active', 'done', 'skipped']).optional(),
144
+ event: z
145
+ .object({
146
+ kind: z.enum(['activity', 'finding', 'conflict', 'artifact', 'batch_case', 'batch_done', 'note']),
147
+ payload: z.record(z.unknown()),
148
+ })
149
+ .optional(),
150
+ }, async ({ stage, status, event }) => {
151
+ if (status)
152
+ await call('PATCH', '/api/onboarding/stage', { stage, status });
153
+ if (event)
154
+ return call('POST', '/api/onboarding/events', { stage, kind: event.kind, payload: event.payload });
155
+ return call('GET', '/api/onboarding');
156
+ });
157
+ server.tool('clearhand_get_onboarding', 'ONBOARDING: read the state machine — stage statuses, and the user\'s confirmations from the GUI (chip answers, conflict resolutions, handling_style with the behavior contract). Read these back BEFORE finalizing overlay files.', {}, async () => call('GET', '/api/onboarding'));
158
+ const transport = new StdioServerTransport();
159
+ await server.connect(transport);
@@ -0,0 +1,52 @@
1
+ import { recordEgress } from '../trust/egress.js';
2
+ function htmlToText(html) {
3
+ return html
4
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
5
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
6
+ .replace(/<br\s*\/?>(?=\s*\S)/gi, '\n')
7
+ .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
8
+ .replace(/<[^>]+>/g, ' ')
9
+ .replace(/&nbsp;/g, ' ')
10
+ .replace(/&amp;/g, '&')
11
+ .replace(/&lt;/g, '<')
12
+ .replace(/&gt;/g, '>')
13
+ .replace(/&#39;|&apos;/g, "'")
14
+ .replace(/&quot;/g, '"')
15
+ .replace(/[ \t]+/g, ' ')
16
+ .replace(/\n\s*\n\s*\n+/g, '\n\n')
17
+ .trim();
18
+ }
19
+ export async function crawlPages(db, urls, maxChars = 20_000) {
20
+ const pages = [];
21
+ for (const url of urls.slice(0, 20)) {
22
+ try {
23
+ const u = new URL(url);
24
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') {
25
+ pages.push({ url, ok: false, title: null, text: null, error: 'unsupported protocol' });
26
+ continue;
27
+ }
28
+ recordEgress(db, u.hostname, 'onboarding extraction (site crawl)');
29
+ const res = await fetch(url, {
30
+ headers: { 'User-Agent': 'clearhand-onboarding/0.1 (+local extraction of your own site)' },
31
+ signal: AbortSignal.timeout(15_000),
32
+ });
33
+ if (!res.ok) {
34
+ pages.push({ url, ok: false, title: null, text: null, error: `HTTP ${res.status}` });
35
+ continue;
36
+ }
37
+ const html = await res.text();
38
+ const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim() ?? null;
39
+ pages.push({ url, ok: true, title, text: htmlToText(html).slice(0, maxChars) });
40
+ }
41
+ catch (err) {
42
+ pages.push({
43
+ url,
44
+ ok: false,
45
+ title: null,
46
+ text: null,
47
+ error: err instanceof Error ? err.message : String(err),
48
+ });
49
+ }
50
+ }
51
+ return pages;
52
+ }
@@ -0,0 +1,45 @@
1
+ import { recordEgress } from '../trust/egress.js';
2
+ export const GITHUB_SECRET_NAME = 'github_token';
3
+ const GITHUB_HOST = 'api.github.com';
4
+ export async function verifyGithub(db, secrets, cfg) {
5
+ try {
6
+ const token = secrets.use(GITHUB_SECRET_NAME, 'verify github token');
7
+ recordEgress(db, GITHUB_HOST, 'verify github token');
8
+ const res = await fetch(`https://${GITHUB_HOST}/repos/${cfg.repo}`, {
9
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' },
10
+ });
11
+ if (!res.ok)
12
+ return { ok: false, error: `GitHub responded ${res.status} for ${cfg.repo}` };
13
+ return { ok: true };
14
+ }
15
+ catch (err) {
16
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
17
+ }
18
+ }
19
+ export function makeGithubOps(db, secrets, cfg) {
20
+ return {
21
+ async createIssue(params, ref) {
22
+ const token = secrets.use(GITHUB_SECRET_NAME, `create issue: ${params.title}`, ref);
23
+ recordEgress(db, GITHUB_HOST, 'create issue');
24
+ const res = await fetch(`https://${GITHUB_HOST}/repos/${cfg.repo}/issues`, {
25
+ method: 'POST',
26
+ headers: {
27
+ Authorization: `Bearer ${token}`,
28
+ Accept: 'application/vnd.github+json',
29
+ 'Content-Type': 'application/json',
30
+ },
31
+ body: JSON.stringify({
32
+ title: params.title,
33
+ body: params.body,
34
+ ...(params.labels ? { labels: params.labels } : {}),
35
+ }),
36
+ });
37
+ if (!res.ok) {
38
+ const text = await res.text().catch(() => '');
39
+ throw new Error(`GitHub issue creation failed: ${res.status} ${text.slice(0, 300)}`);
40
+ }
41
+ const issue = (await res.json());
42
+ return { number: issue.number, url: issue.html_url };
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,257 @@
1
+ import { ImapFlow } from 'imapflow';
2
+ import { simpleParser } from 'mailparser';
3
+ import nodemailer from 'nodemailer';
4
+ import { audit, newestCaseForConversation, now } from '../db/index.js';
5
+ import { recordEgress } from '../trust/egress.js';
6
+ export const MAILBOX_SECRET_NAME = 'mailbox_password';
7
+ export function gmailDefaults(address) {
8
+ return {
9
+ address,
10
+ imapHost: 'imap.gmail.com',
11
+ imapPort: 993,
12
+ smtpHost: 'smtp.gmail.com',
13
+ smtpPort: 465,
14
+ };
15
+ }
16
+ function imapClient(db, secrets, cfg, purpose) {
17
+ const pass = secrets.use(MAILBOX_SECRET_NAME, purpose);
18
+ recordEgress(db, cfg.imapHost, purpose);
19
+ return new ImapFlow({
20
+ host: cfg.imapHost,
21
+ port: cfg.imapPort,
22
+ secure: true,
23
+ auth: { user: cfg.address, pass },
24
+ logger: false,
25
+ });
26
+ }
27
+ export async function verifyMailbox(db, secrets, cfg) {
28
+ try {
29
+ const client = imapClient(db, secrets, cfg, 'verify mailbox login');
30
+ await client.connect();
31
+ await client.logout();
32
+ return { ok: true };
33
+ }
34
+ catch (err) {
35
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
36
+ }
37
+ }
38
+ function upsertCustomer(db, email, name) {
39
+ db.prepare(`INSERT INTO customers (email, name, created_at, updated_at) VALUES (?, ?, ?, ?)
40
+ ON CONFLICT(email) DO UPDATE SET name = COALESCE(customers.name, excluded.name), updated_at = excluded.updated_at`).run(email, name, now(), now());
41
+ return db.prepare(`SELECT id FROM customers WHERE email = ?`).get(email).id;
42
+ }
43
+ function conversationKeyOf(parsed) {
44
+ const refs = parsed.references;
45
+ const root = Array.isArray(refs) ? refs[0] : refs;
46
+ return (root ?? parsed.inReplyTo ?? parsed.messageId ?? `no-id-${Date.now()}`).trim();
47
+ }
48
+ function normalizedSubject(subject) {
49
+ return subject
50
+ .replace(/^\s*((re|fwd?|fw)\s*:\s*)+/i, '')
51
+ .toLowerCase()
52
+ .replace(/\s+/g, ' ')
53
+ .trim()
54
+ .slice(0, 50);
55
+ }
56
+ /**
57
+ * Ingest recent inbox mail into cases.
58
+ *
59
+ * Threading rules ported from the ancestor system:
60
+ * - new activity attaches to the NEWEST case of a conversation, never the
61
+ * original;
62
+ * - a message on a resolved/ignored thread opens a FRESH case linked via
63
+ * parent_case_id (never reopen a case whose actions already executed);
64
+ * - dedupe is by provider Message-ID (unique index), plus a conservative
65
+ * same-sender + normalized-subject match for clients that strip headers.
66
+ */
67
+ export async function syncInbox(db, secrets, cfg, opts = {}) {
68
+ const client = imapClient(db, secrets, cfg, 'sync inbox');
69
+ const result = { scanned: 0, ingested: 0, newCases: 0, followUpCases: 0 };
70
+ await client.connect();
71
+ try {
72
+ const lock = await client.getMailboxLock('INBOX');
73
+ try {
74
+ const since = new Date(Date.now() - (opts.sinceDays ?? 7) * 86_400_000);
75
+ for await (const msg of client.fetch({ since }, { source: true })) {
76
+ result.scanned++;
77
+ const parsed = await simpleParser(msg.source);
78
+ const fromAddr = parsed.from?.value?.[0]?.address?.toLowerCase();
79
+ if (!fromAddr || fromAddr === cfg.address.toLowerCase())
80
+ continue;
81
+ const messageId = parsed.messageId ?? null;
82
+ if (messageId &&
83
+ db.prepare(`SELECT 1 FROM emails WHERE provider_message_id = ?`).get(messageId)) {
84
+ continue;
85
+ }
86
+ const subject = parsed.subject ?? '(no subject)';
87
+ const convKey = conversationKeyOf(parsed);
88
+ let target = newestCaseForConversation(db, 'imap', convKey);
89
+ if (!target && !parsed.inReplyTo) {
90
+ // Fallback threading for header-stripping clients: same sender,
91
+ // same normalized subject, open case, within 7 days.
92
+ target = db
93
+ .prepare(`SELECT c.id, c.status FROM cases c
94
+ JOIN customers cu ON cu.id = c.customer_id
95
+ WHERE cu.email = ? AND c.status NOT IN ('resolved','ignored')
96
+ AND c.created_at > ?
97
+ ORDER BY c.id DESC LIMIT 1`)
98
+ .get(fromAddr, new Date(Date.now() - 7 * 86_400_000).toISOString());
99
+ if (target) {
100
+ const existing = db
101
+ .prepare(`SELECT subject FROM cases WHERE id = ?`)
102
+ .get(target.id);
103
+ if (normalizedSubject(existing.subject ?? '') !== normalizedSubject(subject)) {
104
+ target = undefined;
105
+ }
106
+ }
107
+ }
108
+ const customerId = upsertCustomer(db, fromAddr, parsed.from?.value?.[0]?.name ?? null);
109
+ let caseId;
110
+ if (!target) {
111
+ caseId = createCase(db, customerId, convKey, subject, null);
112
+ result.newCases++;
113
+ }
114
+ else if (['resolved', 'ignored'].includes(target.status)) {
115
+ // Follow-up on a closed thread: fresh linked case.
116
+ caseId = createCase(db, customerId, convKey, subject, target.id);
117
+ result.followUpCases++;
118
+ }
119
+ else {
120
+ caseId = target.id;
121
+ db.prepare(`UPDATE cases SET updated_at = ? WHERE id = ?`).run(now(), caseId);
122
+ }
123
+ db.prepare(`INSERT OR IGNORE INTO emails
124
+ (case_id, direction, from_addr, to_addr, subject, body_text, body_html,
125
+ provider_message_id, in_reply_to, received_at, created_at)
126
+ VALUES (?, 'inbound', ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(caseId, fromAddr, parsed.to && !Array.isArray(parsed.to) ? (parsed.to.value?.[0]?.address ?? null) : null, subject, parsed.text ?? null, typeof parsed.html === 'string' ? parsed.html : null, messageId, parsed.inReplyTo ?? null, (parsed.date ?? new Date()).toISOString(), now());
127
+ result.ingested++;
128
+ }
129
+ }
130
+ finally {
131
+ lock.release();
132
+ }
133
+ }
134
+ finally {
135
+ await client.logout().catch(() => { });
136
+ }
137
+ audit(db, 'system', 'inbox_synced', { detail: result });
138
+ return result;
139
+ }
140
+ function createCase(db, customerId, convKey, subject, parentCaseId) {
141
+ const res = db
142
+ .prepare(`INSERT INTO cases (customer_id, source, conversation_key, subject, status, parent_case_id, created_at, updated_at)
143
+ VALUES (?, 'imap', ?, ?, 'new', ?, ?, ?)`)
144
+ .run(customerId, convKey, subject, parentCaseId, now(), now());
145
+ return Number(res.lastInsertRowid);
146
+ }
147
+ /**
148
+ * Cheap counts for the connect micro-payoff ("Found 1,284 conversations ·
149
+ * 312 sent replies") — STATUS calls only, no message bodies read.
150
+ */
151
+ export async function mailboxInventory(db, secrets, cfg) {
152
+ const client = imapClient(db, secrets, cfg, 'connect inventory (message counts)');
153
+ await client.connect();
154
+ try {
155
+ const boxes = await client.list();
156
+ const sentFolder = boxes.find((b) => b.specialUse === '\\Sent')?.path ??
157
+ boxes.find((b) => /sent/i.test(b.path))?.path ??
158
+ 'Sent';
159
+ const inbox = await client.status('INBOX', { messages: true });
160
+ let sentCount = 0;
161
+ try {
162
+ sentCount = (await client.status(sentFolder, { messages: true })).messages ?? 0;
163
+ }
164
+ catch {
165
+ // no sent folder is survivable — voice learning degrades, connect doesn't
166
+ }
167
+ return {
168
+ inboxMessages: inbox.messages ?? 0,
169
+ sentMessages: sentCount,
170
+ sentFolder,
171
+ };
172
+ }
173
+ finally {
174
+ await client.logout().catch(() => { });
175
+ }
176
+ }
177
+ /**
178
+ * Onboarding extraction: the user's sent mail is the richest context source
179
+ * (voice, de-facto policy, FAQ answers). Read locally, returned transiently
180
+ * for the agent to synthesize overlay files — never persisted by ClearHand.
181
+ */
182
+ export async function fetchSentSamples(db, secrets, cfg, opts = {}) {
183
+ const client = imapClient(db, secrets, cfg, 'onboarding extraction (sent mail)');
184
+ const samples = [];
185
+ await client.connect();
186
+ try {
187
+ const boxes = await client.list();
188
+ const sentBox = boxes.find((b) => b.specialUse === '\\Sent')?.path ??
189
+ boxes.find((b) => /sent/i.test(b.path))?.path ??
190
+ 'Sent';
191
+ const lock = await client.getMailboxLock(sentBox);
192
+ try {
193
+ const since = new Date(Date.now() - (opts.sinceDays ?? 90) * 86_400_000);
194
+ const limit = opts.limit ?? 200;
195
+ for await (const msg of client.fetch({ since }, { source: true })) {
196
+ const parsed = await simpleParser(msg.source);
197
+ samples.push({
198
+ to: parsed.to && !Array.isArray(parsed.to) ? (parsed.to.value?.[0]?.address ?? null) : null,
199
+ subject: parsed.subject ?? null,
200
+ text: parsed.text?.slice(0, 4000) ?? null,
201
+ date: parsed.date?.toISOString() ?? null,
202
+ inReplyTo: parsed.inReplyTo ?? null,
203
+ });
204
+ if (samples.length >= limit)
205
+ break;
206
+ }
207
+ }
208
+ finally {
209
+ lock.release();
210
+ }
211
+ }
212
+ finally {
213
+ await client.logout().catch(() => { });
214
+ }
215
+ return samples;
216
+ }
217
+ /**
218
+ * The executor's mailer. Replies thread onto the customer's original message
219
+ * (In-Reply-To/References) and go out through the provider's SMTP.
220
+ */
221
+ export function makeMailer(db, secrets, cfg) {
222
+ return {
223
+ async sendReply(params) {
224
+ const lastInbound = db
225
+ .prepare(`SELECT provider_message_id, subject FROM emails
226
+ WHERE case_id = ? AND direction = 'inbound'
227
+ ORDER BY id DESC LIMIT 1`)
228
+ .get(params.caseId);
229
+ const pass = secrets.use(MAILBOX_SECRET_NAME, `send reply for case ${params.caseId}`, params.ref);
230
+ recordEgress(db, cfg.smtpHost, 'send customer reply');
231
+ const transport = nodemailer.createTransport({
232
+ host: cfg.smtpHost,
233
+ port: cfg.smtpPort,
234
+ secure: cfg.smtpPort === 465,
235
+ auth: { user: cfg.address, pass },
236
+ });
237
+ const subject = lastInbound?.subject
238
+ ? /^re:/i.test(lastInbound.subject)
239
+ ? lastInbound.subject
240
+ : `Re: ${lastInbound.subject}`
241
+ : 'Re: your message';
242
+ const info = await transport.sendMail({
243
+ from: cfg.displayName ? `"${cfg.displayName}" <${cfg.address}>` : cfg.address,
244
+ to: params.to,
245
+ subject,
246
+ text: params.body,
247
+ ...(lastInbound?.provider_message_id
248
+ ? {
249
+ inReplyTo: lastInbound.provider_message_id,
250
+ references: lastInbound.provider_message_id,
251
+ }
252
+ : {}),
253
+ });
254
+ return { providerMessageId: info.messageId ?? null };
255
+ },
256
+ };
257
+ }