sanook-cli 0.5.1 → 0.5.5

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 (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -0,0 +1,501 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import net from 'node:net';
3
+ import tls from 'node:tls';
4
+ import { hostname } from 'node:os';
5
+ import { redactKey } from '../providers/keys.js';
6
+ import { runGatewayAgent } from './session.js';
7
+ export function normalizeEmailAddress(address) {
8
+ const trimmed = address.trim();
9
+ if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(trimmed)) {
10
+ throw new Error(`email address ไม่ถูกต้อง: ${address}`);
11
+ }
12
+ return trimmed;
13
+ }
14
+ function encodeHeader(raw) {
15
+ const clean = raw.replace(/[\r\n]+/g, ' ').trim();
16
+ return /^[\x20-\x7e]*$/.test(clean) ? clean : `=?UTF-8?B?${Buffer.from(clean, 'utf8').toString('base64')}?=`;
17
+ }
18
+ function dotStuff(body) {
19
+ return body.replace(/\r?\n/g, '\r\n').replace(/^\./gm, '..');
20
+ }
21
+ function messageDomain(address) {
22
+ return address.split('@')[1] || hostname() || 'localhost';
23
+ }
24
+ export function buildEmailMessage(config, to, text, subject = 'Sanook') {
25
+ return buildEmailMessageWithHeaders(config, to, text, { subject });
26
+ }
27
+ export function buildEmailMessageWithHeaders(config, to, text, options = {}) {
28
+ const from = normalizeEmailAddress(config.address);
29
+ const recipient = normalizeEmailAddress(to);
30
+ const messageId = `<${randomUUID()}@${messageDomain(from)}>`;
31
+ const fromHeader = config.fromName ? `${encodeHeader(config.fromName)} <${from}>` : from;
32
+ const refs = [options.references, options.inReplyTo].filter(Boolean).join(' ').trim();
33
+ const headers = [
34
+ `From: ${fromHeader}`,
35
+ `To: ${recipient}`,
36
+ `Subject: ${encodeHeader(options.subject ?? 'Sanook')}`,
37
+ `Date: ${new Date().toUTCString()}`,
38
+ `Message-ID: ${messageId}`,
39
+ ...(options.inReplyTo ? [`In-Reply-To: ${options.inReplyTo}`] : []),
40
+ ...(refs ? [`References: ${refs}`] : []),
41
+ 'MIME-Version: 1.0',
42
+ 'Content-Type: text/plain; charset=UTF-8',
43
+ 'Content-Transfer-Encoding: 8bit',
44
+ ];
45
+ return {
46
+ messageId,
47
+ raw: `${headers.join('\r\n')}\r\n\r\n${dotStuff(text)}\r\n`,
48
+ };
49
+ }
50
+ function connectSocket(host, port, secure) {
51
+ return new Promise((resolve, reject) => {
52
+ const socket = secure
53
+ ? tls.connect({ host, port, servername: host }, () => resolve(socket))
54
+ : net.connect({ host, port }, () => resolve(socket));
55
+ socket.once('error', reject);
56
+ });
57
+ }
58
+ function readResponse(socket, timeoutMs = 20_000) {
59
+ return new Promise((resolve, reject) => {
60
+ let buffer = '';
61
+ const timer = setTimeout(() => cleanup(new Error('SMTP timeout')), timeoutMs);
62
+ const onData = (chunk) => {
63
+ buffer += chunk.toString();
64
+ const lines = buffer.split(/\r?\n/).filter(Boolean);
65
+ const last = lines[lines.length - 1];
66
+ const done = last?.match(/^(\d{3}) /);
67
+ if (done)
68
+ cleanup(undefined, { code: Number(done[1]), text: lines.join('\n') });
69
+ };
70
+ const onError = (e) => cleanup(e);
71
+ const cleanup = (err, value) => {
72
+ clearTimeout(timer);
73
+ socket.off('data', onData);
74
+ socket.off('error', onError);
75
+ if (err)
76
+ reject(err);
77
+ else
78
+ resolve(value);
79
+ };
80
+ socket.on('data', onData);
81
+ socket.once('error', onError);
82
+ });
83
+ }
84
+ async function command(socket, line, ok) {
85
+ socket.write(`${line}\r\n`);
86
+ const response = await readResponse(socket);
87
+ const accepted = Array.isArray(ok) ? ok : [ok];
88
+ if (!accepted.includes(response.code))
89
+ throw new Error(`SMTP ${line.split(/\s+/)[0]} failed: ${response.text}`);
90
+ return response;
91
+ }
92
+ async function wrapStartTls(socket, host) {
93
+ return new Promise((resolve, reject) => {
94
+ const secured = tls.connect({ socket: socket, servername: host }, () => resolve(secured));
95
+ secured.once('error', reject);
96
+ });
97
+ }
98
+ async function sendViaSmtp(email) {
99
+ const port = email.config.smtpPort ?? 587;
100
+ const implicitTls = port === 465;
101
+ let socket = await connectSocket(email.config.smtpHost, port, implicitTls);
102
+ try {
103
+ const greeting = await readResponse(socket);
104
+ if (greeting.code !== 220)
105
+ throw new Error(`SMTP greeting failed: ${greeting.text}`);
106
+ let ehlo = await command(socket, `EHLO ${hostname() || 'localhost'}`, 250);
107
+ if (!implicitTls && /STARTTLS/im.test(ehlo.text)) {
108
+ await command(socket, 'STARTTLS', 220);
109
+ socket = await wrapStartTls(socket, email.config.smtpHost);
110
+ ehlo = await command(socket, `EHLO ${hostname() || 'localhost'}`, 250);
111
+ }
112
+ if (!implicitTls && port !== 25 && !/AUTH/im.test(ehlo.text)) {
113
+ throw new Error('SMTP server did not advertise AUTH after EHLO');
114
+ }
115
+ const auth = Buffer.from(`\0${email.config.address}\0${email.config.password}`, 'utf8').toString('base64');
116
+ await command(socket, `AUTH PLAIN ${auth}`, 235);
117
+ await command(socket, `MAIL FROM:<${normalizeEmailAddress(email.config.address)}>`, 250);
118
+ await command(socket, `RCPT TO:<${normalizeEmailAddress(email.to)}>`, [250, 251]);
119
+ await command(socket, 'DATA', 354);
120
+ socket.write(`${email.raw}\r\n.\r\n`);
121
+ const sent = await readResponse(socket);
122
+ if (sent.code !== 250)
123
+ throw new Error(`SMTP DATA failed: ${sent.text}`);
124
+ await command(socket, 'QUIT', 221).catch(() => { });
125
+ }
126
+ finally {
127
+ socket.end();
128
+ }
129
+ }
130
+ export async function sendEmailMessage(config, to, text, options = {}) {
131
+ if (!config.address || !config.password || !config.smtpHost) {
132
+ throw new Error('Email ต้องมี address, password และ smtpHost');
133
+ }
134
+ const recipient = normalizeEmailAddress(to);
135
+ const { raw, messageId } = buildEmailMessageWithHeaders(config, recipient, text, options);
136
+ await (options.transport ?? sendViaSmtp)({ config, to: recipient, raw });
137
+ return { to: recipient, messageId };
138
+ }
139
+ function headerValue(raw, name) {
140
+ const lines = raw.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/);
141
+ const prefix = `${name.toLowerCase()}:`;
142
+ const found = lines.find((line) => line.toLowerCase().startsWith(prefix));
143
+ return found?.slice(prefix.length).trim();
144
+ }
145
+ function splitHeaderBody(raw) {
146
+ const match = raw.match(/\r?\n\r?\n/);
147
+ if (!match || match.index == null)
148
+ return { headers: raw, body: '' };
149
+ return { headers: raw.slice(0, match.index), body: raw.slice(match.index + match[0].length) };
150
+ }
151
+ function decodeQuotedPrintableHeader(raw) {
152
+ const bytes = [];
153
+ for (let i = 0; i < raw.length; i += 1) {
154
+ const char = raw[i];
155
+ if (char === '_') {
156
+ bytes.push(0x20);
157
+ continue;
158
+ }
159
+ if (char === '=' && /^[0-9A-Fa-f]{2}$/.test(raw.slice(i + 1, i + 3))) {
160
+ bytes.push(Number.parseInt(raw.slice(i + 1, i + 3), 16));
161
+ i += 2;
162
+ continue;
163
+ }
164
+ bytes.push(...Buffer.from(char, 'utf8'));
165
+ }
166
+ return Buffer.from(bytes).toString('utf8');
167
+ }
168
+ function decodeHeader(raw) {
169
+ if (!raw)
170
+ return '';
171
+ return raw.replace(/=\?utf-8\?([bq])\?([^?]+)\?=/gi, (_m, encoding, value) => encoding.toLowerCase() === 'b' ? Buffer.from(value, 'base64').toString('utf8') : decodeQuotedPrintableHeader(value));
172
+ }
173
+ export function extractEmailAddress(raw) {
174
+ const match = raw.match(/<([^<>@\s]+@[^<>@\s]+)>/);
175
+ return (match?.[1] ?? raw).replace(/^mailto:/i, '').trim().toLowerCase();
176
+ }
177
+ function stripHtml(raw) {
178
+ return raw
179
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
180
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
181
+ .replace(/<br\s*\/?>/gi, '\n')
182
+ .replace(/<\/p>/gi, '\n')
183
+ .replace(/<[^>]+>/g, '')
184
+ .replace(/&nbsp;/g, ' ')
185
+ .replace(/&lt;/g, '<')
186
+ .replace(/&gt;/g, '>')
187
+ .replace(/&amp;/g, '&');
188
+ }
189
+ function decodeTransfer(raw, encoding) {
190
+ if (/base64/i.test(encoding ?? ''))
191
+ return Buffer.from(raw.replace(/\s+/g, ''), 'base64').toString('utf8');
192
+ if (/quoted-printable/i.test(encoding ?? '')) {
193
+ return raw
194
+ .replace(/=\r?\n/g, '')
195
+ .replace(/=([0-9A-F]{2})/gi, (_m, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
196
+ }
197
+ return raw;
198
+ }
199
+ function messageBody(raw) {
200
+ const { headers, body } = splitHeaderBody(raw);
201
+ if (!body)
202
+ return '';
203
+ const contentType = headerValue(headers, 'Content-Type') ?? '';
204
+ const encoding = headerValue(headers, 'Content-Transfer-Encoding');
205
+ const boundary = contentType.match(/boundary="?([^";]+)"?/i)?.[1];
206
+ if (boundary) {
207
+ const parts = body.split(`--${boundary}`);
208
+ const textPart = parts.find((part) => /content-type:\s*text\/plain/i.test(part));
209
+ const htmlPart = parts.find((part) => /content-type:\s*text\/html/i.test(part));
210
+ const chosen = textPart ?? htmlPart ?? '';
211
+ const { headers: partHeaders, body: partBody } = splitHeaderBody(chosen);
212
+ if (partBody) {
213
+ const decoded = decodeTransfer(partBody, headerValue(partHeaders, 'Content-Transfer-Encoding'));
214
+ return /content-type:\s*text\/html/i.test(partHeaders) ? stripHtml(decoded).trim() : decoded.trim();
215
+ }
216
+ }
217
+ const decoded = decodeTransfer(body, encoding);
218
+ return /text\/html/i.test(contentType) ? stripHtml(decoded).trim() : decoded.trim();
219
+ }
220
+ export function parseRawEmail(uid, raw) {
221
+ const { headers } = splitHeaderBody(raw);
222
+ return {
223
+ uid,
224
+ from: extractEmailAddress(headerValue(headers, 'From') ?? ''),
225
+ subject: decodeHeader(headerValue(headers, 'Subject')) || '(no subject)',
226
+ text: messageBody(raw),
227
+ messageId: headerValue(headers, 'Message-ID'),
228
+ references: headerValue(headers, 'References'),
229
+ autoSubmitted: headerValue(headers, 'Auto-Submitted'),
230
+ precedence: headerValue(headers, 'Precedence'),
231
+ listUnsubscribe: headerValue(headers, 'List-Unsubscribe'),
232
+ authResults: headerValue(headers, 'Authentication-Results'),
233
+ };
234
+ }
235
+ function domainMatches(claimed, fromDomain) {
236
+ const c = claimed.toLowerCase().replace(/^.*@/, '').replace(/[>\s]+$/, '').trim();
237
+ const f = fromDomain.toLowerCase().trim();
238
+ if (!c || !f)
239
+ return false;
240
+ return c === f || f.endsWith(`.${c}`) || c.endsWith(`.${f}`);
241
+ }
242
+ /** True only if Authentication-Results shows SPF or DKIM = pass, aligned to the From domain (DMARC-style). */
243
+ export function senderPassesAuth(authResults, fromDomain) {
244
+ if (!authResults || !fromDomain)
245
+ return false;
246
+ const ar = authResults.toLowerCase();
247
+ for (const m of ar.matchAll(/dkim=pass[^;]*?header\.d=([a-z0-9.\-]+)/g)) {
248
+ if (domainMatches(m[1], fromDomain))
249
+ return true;
250
+ }
251
+ for (const m of ar.matchAll(/spf=pass[^;]*?(?:smtp\.mailfrom|envelope-from)=([^;\s]+)/g)) {
252
+ if (domainMatches(m[1], fromDomain))
253
+ return true;
254
+ }
255
+ return false;
256
+ }
257
+ export function shouldProcessEmail(email, config) {
258
+ const from = extractEmailAddress(email.from);
259
+ const self = extractEmailAddress(config.address);
260
+ if (!from || from === self)
261
+ return false;
262
+ if (/^(no-?reply|mailer-daemon|postmaster|bounce)@/i.test(from))
263
+ return false;
264
+ if (email.autoSubmitted && !/^no$/i.test(email.autoSubmitted))
265
+ return false;
266
+ if (/bulk|junk|list/i.test(email.precedence ?? ''))
267
+ return false;
268
+ if (email.listUnsubscribe)
269
+ return false;
270
+ if (config.allowAllUsers)
271
+ return true;
272
+ const allowed = new Set((config.allowedUsers ?? []).map((s) => s.toLowerCase()));
273
+ if (!allowed.has(from))
274
+ return false;
275
+ // From is trivially spoofable — an allowlisted address must also pass SPF/DKIM aligned to its
276
+ // domain. Fail closed unless the operator explicitly opted out (MTA doesn't stamp auth results).
277
+ if (config.allowUnauthenticatedSenders)
278
+ return true;
279
+ return senderPassesAuth(email.authResults, from.split('@')[1] ?? '');
280
+ }
281
+ function imapQuote(raw) {
282
+ return `"${raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
283
+ }
284
+ function escapeRegExp(raw) {
285
+ return raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
286
+ }
287
+ function readImapGreeting(socket, timeoutMs = 30_000) {
288
+ return new Promise((resolve, reject) => {
289
+ let buffer = '';
290
+ const timer = setTimeout(() => cleanup(new Error('IMAP greeting timeout')), timeoutMs);
291
+ const onData = (chunk) => {
292
+ buffer += chunk.toString('utf8');
293
+ if (/^\* (OK|PREAUTH|BYE|BAD)/m.test(buffer))
294
+ cleanup(undefined, buffer);
295
+ };
296
+ const onError = (e) => cleanup(e);
297
+ const cleanup = (err, value) => {
298
+ clearTimeout(timer);
299
+ socket.off('data', onData);
300
+ socket.off('error', onError);
301
+ if (err)
302
+ reject(err);
303
+ else
304
+ resolve(value ?? buffer);
305
+ };
306
+ socket.on('data', onData);
307
+ socket.once('error', onError);
308
+ });
309
+ }
310
+ function readImapResponse(socket, tag, timeoutMs = 30_000) {
311
+ return new Promise((resolve, reject) => {
312
+ let buffer = '';
313
+ const tagPattern = escapeRegExp(tag);
314
+ const timer = setTimeout(() => cleanup(new Error('IMAP timeout')), timeoutMs);
315
+ const onData = (chunk) => {
316
+ buffer += chunk.toString('utf8');
317
+ if (new RegExp(`^${tagPattern} (OK|NO|BAD)`, 'm').test(buffer))
318
+ cleanup(undefined, buffer);
319
+ };
320
+ const onError = (e) => cleanup(e);
321
+ const cleanup = (err, value) => {
322
+ clearTimeout(timer);
323
+ socket.off('data', onData);
324
+ socket.off('error', onError);
325
+ if (err)
326
+ reject(err);
327
+ else
328
+ resolve(value ?? buffer);
329
+ };
330
+ socket.on('data', onData);
331
+ socket.once('error', onError);
332
+ });
333
+ }
334
+ async function imapCommand(socket, tag, commandText) {
335
+ socket.write(`${tag} ${commandText}\r\n`);
336
+ const response = await readImapResponse(socket, tag);
337
+ if (!new RegExp(`^${escapeRegExp(tag)} OK`, 'm').test(response))
338
+ throw new Error(`IMAP ${commandText.split(/\s+/)[0]} failed: ${response}`);
339
+ return response;
340
+ }
341
+ export function parseImapSearchResponse(response) {
342
+ const line = response.split(/\r?\n/).find((l) => l.startsWith('* SEARCH'));
343
+ if (!line)
344
+ return [];
345
+ return line
346
+ .slice('* SEARCH'.length)
347
+ .trim()
348
+ .split(/\s+/)
349
+ .filter(Boolean)
350
+ .map(Number)
351
+ .filter((n) => Number.isSafeInteger(n));
352
+ }
353
+ export function parseImapFetchResponse(response) {
354
+ const out = [];
355
+ const marker = /UID\s+(\d+)[\s\S]*?BODY(?:\.PEEK)?\[\]\s+\{(\d+)\}\r?\n/gim;
356
+ let match;
357
+ while ((match = marker.exec(response))) {
358
+ const uid = Number(match[1]);
359
+ const len = Number(match[2]);
360
+ const bodyStart = marker.lastIndex;
361
+ const raw = response.slice(bodyStart, bodyStart + len);
362
+ if (Number.isSafeInteger(uid) && raw)
363
+ out.push(parseRawEmail(uid, raw));
364
+ marker.lastIndex = bodyStart + len;
365
+ }
366
+ return out;
367
+ }
368
+ async function connectImap(config) {
369
+ const port = config.imapPort ?? 993;
370
+ return connectSocket(config.imapHost, port, port === 993);
371
+ }
372
+ export async function fetchUnseenEmails(config) {
373
+ if (!config.address || !config.password || !config.imapHost)
374
+ throw new Error('Email ต้องมี address, password และ imapHost');
375
+ const socket = await connectImap(config);
376
+ try {
377
+ const greeting = await readImapGreeting(socket).catch(() => '');
378
+ if (greeting && /^(\* )?BAD/im.test(greeting))
379
+ throw new Error(`IMAP greeting failed: ${greeting}`);
380
+ await imapCommand(socket, 'A1', `LOGIN ${imapQuote(config.address)} ${imapQuote(config.password)}`);
381
+ await imapCommand(socket, 'A2', 'SELECT INBOX');
382
+ const search = await imapCommand(socket, 'A3', 'UID SEARCH UNSEEN');
383
+ const uids = parseImapSearchResponse(search);
384
+ if (!uids.length)
385
+ return [];
386
+ const fetch = await imapCommand(socket, 'A4', `UID FETCH ${uids.join(',')} (UID BODY.PEEK[])`);
387
+ return parseImapFetchResponse(fetch);
388
+ }
389
+ finally {
390
+ await imapCommand(socket, 'ZZ', 'LOGOUT').catch(() => { });
391
+ socket.end();
392
+ }
393
+ }
394
+ export async function markEmailSeen(config, uid) {
395
+ if (!config.address || !config.password || !config.imapHost)
396
+ throw new Error('Email ต้องมี address, password และ imapHost');
397
+ const socket = await connectImap(config);
398
+ try {
399
+ await readImapGreeting(socket).catch(() => '');
400
+ await imapCommand(socket, 'A1', `LOGIN ${imapQuote(config.address)} ${imapQuote(config.password)}`);
401
+ await imapCommand(socket, 'A2', 'SELECT INBOX');
402
+ await imapCommand(socket, 'A3', `UID STORE ${uid} +FLAGS.SILENT (\\Seen)`);
403
+ }
404
+ finally {
405
+ await imapCommand(socket, 'ZZ', 'LOGOUT').catch(() => { });
406
+ socket.end();
407
+ }
408
+ }
409
+ function replySubject(subject) {
410
+ return /^re:/i.test(subject) ? subject : `Re: ${subject}`;
411
+ }
412
+ export async function sendEmailReply(config, email, text) {
413
+ await sendEmailMessage(config, email.from, text || '(ไม่มีผลลัพธ์)', {
414
+ subject: replySubject(email.subject),
415
+ inReplyTo: email.messageId,
416
+ references: email.references,
417
+ });
418
+ }
419
+ function emailPrompt(email) {
420
+ return [
421
+ `Email from: ${email.from}`,
422
+ `Subject: ${email.subject}`,
423
+ '',
424
+ 'Message:',
425
+ email.text || '(empty)',
426
+ ].join('\n');
427
+ }
428
+ export function startEmail(opts) {
429
+ if (!opts.address || !opts.password || !opts.imapHost || !opts.smtpHost) {
430
+ opts.onLog?.('Email ไม่เริ่ม: ต้องตั้ง address/password/imapHost/smtpHost');
431
+ return () => { };
432
+ }
433
+ if (!opts.allowAllUsers && !opts.allowedUsers?.length) {
434
+ opts.onLog?.('⛔ Email ไม่เริ่ม: ต้องตั้ง allowedUsers หรือ allowAllUsers — remote surface นี้รัน agent ได้');
435
+ return () => { };
436
+ }
437
+ const pollMs = Math.max(5, opts.pollIntervalSeconds ?? 15) * 1000;
438
+ const poller = opts.poller ?? fetchUnseenEmails;
439
+ const markSeen = opts.markSeen ?? markEmailSeen;
440
+ const sendReply = opts.sendReply ?? sendEmailReply;
441
+ const running = new Set();
442
+ let stopped = false;
443
+ let busy = false;
444
+ opts.onLog?.(`Email: IMAP polling เริ่มแล้ว (${opts.address}, ทุก ${pollMs / 1000}s)`);
445
+ async function handle(email) {
446
+ if (running.has(email.uid))
447
+ return;
448
+ running.add(email.uid);
449
+ try {
450
+ if (!shouldProcessEmail(email, opts)) {
451
+ opts.onLog?.(`Email: ข้าม ${email.uid} จาก ${email.from} (ไม่ผ่าน policy)`);
452
+ return;
453
+ }
454
+ opts.onLog?.(`Email ${email.uid} จาก ${email.from}: ${email.subject.slice(0, 60)}`);
455
+ try {
456
+ const result = await runGatewayAgent({
457
+ platform: 'email',
458
+ target: email.from,
459
+ model: opts.model,
460
+ prompt: emailPrompt(email),
461
+ userText: email.text,
462
+ budgetUsd: opts.budgetUsd,
463
+ permissionMode: opts.allowWrite === true ? 'auto' : 'ask',
464
+ });
465
+ if (!result.suppressDelivery)
466
+ await sendReply(opts, email, result.text || '(ไม่มีผลลัพธ์)');
467
+ }
468
+ catch (e) {
469
+ opts.onLog?.(`Email run error (${email.uid}): ${redactKey(e.message)}`);
470
+ await sendReply(opts, email, 'เกิดข้อผิดพลาดภายใน').catch((err) => opts.onLog?.(`Email reply error (${email.uid}): ${redactKey(err.message)}`));
471
+ }
472
+ }
473
+ finally {
474
+ await markSeen(opts, email.uid).catch((e) => opts.onLog?.(`Email mark-seen error (${email.uid}): ${redactKey(e.message)}`));
475
+ running.delete(email.uid);
476
+ }
477
+ }
478
+ async function tick() {
479
+ if (stopped || busy)
480
+ return;
481
+ busy = true;
482
+ try {
483
+ const emails = await poller(opts);
484
+ for (const email of emails)
485
+ await handle(email);
486
+ }
487
+ catch (e) {
488
+ if (!stopped)
489
+ opts.onLog?.(`Email poll error: ${redactKey(e.message)}`);
490
+ }
491
+ finally {
492
+ busy = false;
493
+ }
494
+ }
495
+ void tick();
496
+ const timer = setInterval(() => void tick(), pollMs);
497
+ return () => {
498
+ stopped = true;
499
+ clearInterval(timer);
500
+ };
501
+ }