nothumanallowed 12.7.0 → 13.2.13
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/LICENSE +21 -0
- package/README.md +185 -707
- package/bin/nha.mjs +35 -1
- package/package.json +2 -2
- package/src/commands/ui.mjs +484 -113
- package/src/config.mjs +0 -46
- package/src/constants.mjs +1 -1
- package/src/services/google-oauth.mjs +3 -8
- package/src/services/llm.mjs +140 -6
- package/src/services/tool-executor.mjs +2 -178
- package/src/services/web-ui.mjs +1452 -474
- package/src/services/imap-email.mjs +0 -428
|
@@ -1,428 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Multi-provider IMAP/SMTP email service.
|
|
3
|
-
*
|
|
4
|
-
* - Read-only IMAP: downloads messages, marks as seen on local DB only.
|
|
5
|
-
* NEVER deletes, moves, or modifies messages on the remote server.
|
|
6
|
-
* - SMTP send: each account has its own SMTP config.
|
|
7
|
-
* - Multiple accounts supported, each independent.
|
|
8
|
-
* - Zero npm dependencies: uses Node.js 22 native net/tls + custom IMAP/SMTP.
|
|
9
|
-
*
|
|
10
|
-
* Config: ~/.nha/config.json → emailAccounts[]
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import net from 'net';
|
|
14
|
-
import tls from 'tls';
|
|
15
|
-
import crypto from 'crypto';
|
|
16
|
-
import fs from 'fs';
|
|
17
|
-
import path from 'path';
|
|
18
|
-
import { NHA_DIR } from '../constants.mjs';
|
|
19
|
-
|
|
20
|
-
const EMAIL_DB_DIR = path.join(NHA_DIR, 'email-cache');
|
|
21
|
-
|
|
22
|
-
// ── IMAP Client (read-only, POP3-like behavior) ────────────────────────
|
|
23
|
-
|
|
24
|
-
class IMAPClient {
|
|
25
|
-
constructor(host, port, user, pass, useTLS = true) {
|
|
26
|
-
this.host = host;
|
|
27
|
-
this.port = port;
|
|
28
|
-
this.user = user;
|
|
29
|
-
this.pass = pass;
|
|
30
|
-
this.useTLS = useTLS;
|
|
31
|
-
this.socket = null;
|
|
32
|
-
this.tagCounter = 0;
|
|
33
|
-
this.buffer = '';
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Connect and authenticate */
|
|
37
|
-
async connect() {
|
|
38
|
-
return new Promise((resolve, reject) => {
|
|
39
|
-
const timeout = setTimeout(() => reject(new Error('IMAP connection timeout')), 15000);
|
|
40
|
-
|
|
41
|
-
const onConnect = () => {
|
|
42
|
-
clearTimeout(timeout);
|
|
43
|
-
this._readResponse().then(() => {
|
|
44
|
-
return this._command(`LOGIN "${this.user}" "${this.pass}"`);
|
|
45
|
-
}).then(res => {
|
|
46
|
-
if (res.includes('OK')) resolve();
|
|
47
|
-
else reject(new Error('IMAP login failed'));
|
|
48
|
-
}).catch(reject);
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
if (this.useTLS) {
|
|
52
|
-
this.socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: true }, onConnect);
|
|
53
|
-
} else {
|
|
54
|
-
this.socket = net.connect({ host: this.host, port: this.port }, onConnect);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
this.socket.setEncoding('utf-8');
|
|
58
|
-
this.socket.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Send IMAP command and get response */
|
|
63
|
-
async _command(cmd) {
|
|
64
|
-
const tag = `A${++this.tagCounter}`;
|
|
65
|
-
return new Promise((resolve, reject) => {
|
|
66
|
-
const timeout = setTimeout(() => reject(new Error('IMAP command timeout')), 30000);
|
|
67
|
-
let response = '';
|
|
68
|
-
|
|
69
|
-
const onData = (chunk) => {
|
|
70
|
-
response += chunk;
|
|
71
|
-
// Check if we have the tagged response line
|
|
72
|
-
if (response.includes(`${tag} OK`) || response.includes(`${tag} NO`) || response.includes(`${tag} BAD`)) {
|
|
73
|
-
clearTimeout(timeout);
|
|
74
|
-
this.socket.removeListener('data', onData);
|
|
75
|
-
resolve(response);
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
this.socket.on('data', onData);
|
|
80
|
-
this.socket.write(`${tag} ${cmd}\r\n`);
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Read server greeting */
|
|
85
|
-
async _readResponse() {
|
|
86
|
-
return new Promise((resolve) => {
|
|
87
|
-
const onData = (chunk) => {
|
|
88
|
-
this.buffer += chunk;
|
|
89
|
-
if (this.buffer.includes('\r\n')) {
|
|
90
|
-
this.socket.removeListener('data', onData);
|
|
91
|
-
resolve(this.buffer);
|
|
92
|
-
this.buffer = '';
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
this.socket.on('data', onData);
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** List recent messages (headers only). NEVER modifies server state. */
|
|
100
|
-
async listMessages(folder = 'INBOX', limit = 20) {
|
|
101
|
-
await this._command(`SELECT "${folder}"`);
|
|
102
|
-
|
|
103
|
-
// Get message count from SELECT response
|
|
104
|
-
const searchRes = await this._command('SEARCH ALL');
|
|
105
|
-
const ids = searchRes.match(/\* SEARCH ([\d\s]+)/)?.[1]?.trim().split(/\s+/) || [];
|
|
106
|
-
const recent = ids.slice(-limit);
|
|
107
|
-
|
|
108
|
-
if (recent.length === 0) return [];
|
|
109
|
-
|
|
110
|
-
const messages = [];
|
|
111
|
-
for (const id of recent) {
|
|
112
|
-
try {
|
|
113
|
-
const headerRes = await this._command(`FETCH ${id} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID)])`);
|
|
114
|
-
|
|
115
|
-
const from = headerRes.match(/From:\s*(.+)/i)?.[1]?.trim() || '';
|
|
116
|
-
const to = headerRes.match(/To:\s*(.+)/i)?.[1]?.trim() || '';
|
|
117
|
-
const subject = headerRes.match(/Subject:\s*(.+)/i)?.[1]?.trim() || '';
|
|
118
|
-
const date = headerRes.match(/Date:\s*(.+)/i)?.[1]?.trim() || '';
|
|
119
|
-
const messageId = headerRes.match(/Message-ID:\s*(.+)/i)?.[1]?.trim() || id;
|
|
120
|
-
const seen = headerRes.includes('\\Seen');
|
|
121
|
-
|
|
122
|
-
messages.push({ id, messageId, from, to, subject, date, seen });
|
|
123
|
-
} catch { /* skip unparseable messages */ }
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return messages.reverse(); // newest first
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Read full message body. Uses BODY.PEEK to NOT mark as read on server. */
|
|
130
|
-
async readMessage(id) {
|
|
131
|
-
const res = await this._command(`FETCH ${id} BODY.PEEK[]`);
|
|
132
|
-
|
|
133
|
-
// Parse basic MIME — extract text/plain or text/html part
|
|
134
|
-
const body = res.replace(/^\* \d+ FETCH.*?\r\n/m, '').replace(/\)\r\nA\d+ OK.*$/s, '');
|
|
135
|
-
|
|
136
|
-
// Try to extract text/plain content
|
|
137
|
-
const plainMatch = body.match(/Content-Type:\s*text\/plain[\s\S]*?\r\n\r\n([\s\S]*?)(?:\r\n--|\r\nA\d+)/i);
|
|
138
|
-
if (plainMatch) return decodeBody(plainMatch[1]);
|
|
139
|
-
|
|
140
|
-
// Fallback: strip HTML tags
|
|
141
|
-
const htmlMatch = body.match(/Content-Type:\s*text\/html[\s\S]*?\r\n\r\n([\s\S]*?)(?:\r\n--|\r\nA\d+)/i);
|
|
142
|
-
if (htmlMatch) return stripHtml(decodeBody(htmlMatch[1]));
|
|
143
|
-
|
|
144
|
-
// Last resort: return raw (first 5000 chars)
|
|
145
|
-
return body.slice(0, 5000);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async disconnect() {
|
|
149
|
-
try {
|
|
150
|
-
await this._command('LOGOUT');
|
|
151
|
-
} catch {} finally {
|
|
152
|
-
this.socket?.destroy();
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── SMTP Client ─────────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
class SMTPClient {
|
|
160
|
-
constructor(host, port, user, pass, useTLS = true) {
|
|
161
|
-
this.host = host;
|
|
162
|
-
this.port = port;
|
|
163
|
-
this.user = user;
|
|
164
|
-
this.pass = pass;
|
|
165
|
-
this.useTLS = useTLS;
|
|
166
|
-
this.socket = null;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async connect() {
|
|
170
|
-
return new Promise((resolve, reject) => {
|
|
171
|
-
const timeout = setTimeout(() => reject(new Error('SMTP connection timeout')), 15000);
|
|
172
|
-
|
|
173
|
-
const afterConnect = async () => {
|
|
174
|
-
clearTimeout(timeout);
|
|
175
|
-
try {
|
|
176
|
-
await this._readLine(); // greeting
|
|
177
|
-
|
|
178
|
-
// EHLO
|
|
179
|
-
await this._send(`EHLO nha-client`);
|
|
180
|
-
const ehloRes = await this._readMultiLine();
|
|
181
|
-
|
|
182
|
-
// STARTTLS if port 587 and not already TLS
|
|
183
|
-
if (this.port === 587 && !this.useTLS && ehloRes.includes('STARTTLS')) {
|
|
184
|
-
await this._send('STARTTLS');
|
|
185
|
-
await this._readLine();
|
|
186
|
-
// Upgrade to TLS
|
|
187
|
-
this.socket = await this._upgradeTLS();
|
|
188
|
-
await this._send(`EHLO nha-client`);
|
|
189
|
-
await this._readMultiLine();
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// AUTH LOGIN
|
|
193
|
-
await this._send('AUTH LOGIN');
|
|
194
|
-
const authRes = await this._readLine();
|
|
195
|
-
if (!authRes.startsWith('334')) throw new Error('SMTP AUTH not supported');
|
|
196
|
-
|
|
197
|
-
await this._send(Buffer.from(this.user).toString('base64'));
|
|
198
|
-
await this._readLine();
|
|
199
|
-
|
|
200
|
-
await this._send(Buffer.from(this.pass).toString('base64'));
|
|
201
|
-
const loginRes = await this._readLine();
|
|
202
|
-
if (!loginRes.startsWith('235')) throw new Error('SMTP login failed');
|
|
203
|
-
|
|
204
|
-
resolve();
|
|
205
|
-
} catch (e) { reject(e); }
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
if (this.useTLS || this.port === 465) {
|
|
209
|
-
this.socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: true }, afterConnect);
|
|
210
|
-
} else {
|
|
211
|
-
this.socket = net.connect({ host: this.host, port: this.port }, afterConnect);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
this.socket.setEncoding('utf-8');
|
|
215
|
-
this.socket.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async _send(data) {
|
|
220
|
-
return new Promise((resolve, reject) => {
|
|
221
|
-
this.socket.write(data + '\r\n', (err) => err ? reject(err) : resolve());
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async _readLine() {
|
|
226
|
-
return new Promise((resolve) => {
|
|
227
|
-
let buf = '';
|
|
228
|
-
const onData = (chunk) => {
|
|
229
|
-
buf += chunk;
|
|
230
|
-
if (buf.includes('\r\n')) {
|
|
231
|
-
this.socket.removeListener('data', onData);
|
|
232
|
-
resolve(buf.trim());
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
this.socket.on('data', onData);
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async _readMultiLine() {
|
|
240
|
-
return new Promise((resolve) => {
|
|
241
|
-
let buf = '';
|
|
242
|
-
const onData = (chunk) => {
|
|
243
|
-
buf += chunk;
|
|
244
|
-
// Multi-line ends with a line that has space after status code (e.g., "250 OK")
|
|
245
|
-
const lines = buf.split('\r\n');
|
|
246
|
-
const lastLine = lines.filter(l => l.length > 0).pop() || '';
|
|
247
|
-
if (lastLine.match(/^\d{3} /)) {
|
|
248
|
-
this.socket.removeListener('data', onData);
|
|
249
|
-
resolve(buf.trim());
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
this.socket.on('data', onData);
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async _upgradeTLS() {
|
|
257
|
-
return new Promise((resolve, reject) => {
|
|
258
|
-
const upgraded = tls.connect({ socket: this.socket, host: this.host, rejectUnauthorized: true }, () => {
|
|
259
|
-
this.socket = upgraded;
|
|
260
|
-
resolve(upgraded);
|
|
261
|
-
});
|
|
262
|
-
upgraded.on('error', reject);
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Send email */
|
|
267
|
-
async send(from, to, subject, body, cc = '', bcc = '') {
|
|
268
|
-
// MAIL FROM
|
|
269
|
-
await this._send(`MAIL FROM:<${from}>`);
|
|
270
|
-
const fromRes = await this._readLine();
|
|
271
|
-
if (!fromRes.startsWith('250')) throw new Error(`MAIL FROM rejected: ${fromRes}`);
|
|
272
|
-
|
|
273
|
-
// RCPT TO (multiple recipients)
|
|
274
|
-
const recipients = [to, ...cc.split(','), ...bcc.split(',')].filter(Boolean).map(e => e.trim());
|
|
275
|
-
for (const rcpt of recipients) {
|
|
276
|
-
const addr = rcpt.match(/<([^>]+)>/)?.[1] || rcpt;
|
|
277
|
-
await this._send(`RCPT TO:<${addr}>`);
|
|
278
|
-
const rcptRes = await this._readLine();
|
|
279
|
-
if (!rcptRes.startsWith('250')) throw new Error(`RCPT TO rejected for ${addr}: ${rcptRes}`);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// DATA
|
|
283
|
-
await this._send('DATA');
|
|
284
|
-
const dataRes = await this._readLine();
|
|
285
|
-
if (!dataRes.startsWith('354')) throw new Error(`DATA rejected: ${dataRes}`);
|
|
286
|
-
|
|
287
|
-
// Headers + body
|
|
288
|
-
const msgId = `<${crypto.randomUUID()}@nha>`;
|
|
289
|
-
const date = new Date().toUTCString();
|
|
290
|
-
let msg = `From: ${from}\r\n`;
|
|
291
|
-
msg += `To: ${to}\r\n`;
|
|
292
|
-
if (cc) msg += `Cc: ${cc}\r\n`;
|
|
293
|
-
msg += `Subject: ${subject}\r\n`;
|
|
294
|
-
msg += `Date: ${date}\r\n`;
|
|
295
|
-
msg += `Message-ID: ${msgId}\r\n`;
|
|
296
|
-
msg += `MIME-Version: 1.0\r\n`;
|
|
297
|
-
msg += `Content-Type: text/plain; charset=UTF-8\r\n`;
|
|
298
|
-
msg += `Content-Transfer-Encoding: 8bit\r\n`;
|
|
299
|
-
msg += `X-Mailer: NHA/2.0\r\n`;
|
|
300
|
-
msg += `\r\n${body}\r\n.\r\n`;
|
|
301
|
-
|
|
302
|
-
await this._send(msg);
|
|
303
|
-
const sendRes = await this._readLine();
|
|
304
|
-
if (!sendRes.startsWith('250')) throw new Error(`Send failed: ${sendRes}`);
|
|
305
|
-
|
|
306
|
-
return { messageId: msgId, success: true };
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async disconnect() {
|
|
310
|
-
try {
|
|
311
|
-
await this._send('QUIT');
|
|
312
|
-
await this._readLine();
|
|
313
|
-
} catch {} finally {
|
|
314
|
-
this.socket?.destroy();
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
320
|
-
|
|
321
|
-
function decodeBody(text) {
|
|
322
|
-
// Handle quoted-printable
|
|
323
|
-
return text.replace(/=\r?\n/g, '').replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function stripHtml(html) {
|
|
327
|
-
return html
|
|
328
|
-
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
329
|
-
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
330
|
-
.replace(/<[^>]+>/g, ' ')
|
|
331
|
-
.replace(/ /g, ' ')
|
|
332
|
-
.replace(/&/g, '&')
|
|
333
|
-
.replace(/</g, '<')
|
|
334
|
-
.replace(/>/g, '>')
|
|
335
|
-
.replace(/"/g, '"')
|
|
336
|
-
.replace(/\s+/g, ' ')
|
|
337
|
-
.trim();
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ── Public API ──────────────────────────────────────────────────────────
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* List messages from an IMAP account. Read-only, never modifies server.
|
|
344
|
-
* @param {object} account - { imap: { host, port, user, pass, tls }, label }
|
|
345
|
-
* @param {number} limit
|
|
346
|
-
* @returns {Array<{ id, from, to, subject, date, seen }>}
|
|
347
|
-
*/
|
|
348
|
-
export async function listImapMessages(account, limit = 20) {
|
|
349
|
-
const { host, port, user, pass, tls: useTLS } = account.imap;
|
|
350
|
-
const client = new IMAPClient(host, port || 993, user, pass, useTLS !== false);
|
|
351
|
-
try {
|
|
352
|
-
await client.connect();
|
|
353
|
-
return await client.listMessages('INBOX', limit);
|
|
354
|
-
} finally {
|
|
355
|
-
await client.disconnect();
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Read a single message. Uses BODY.PEEK — does NOT mark as read on server.
|
|
361
|
-
* @param {object} account
|
|
362
|
-
* @param {string} messageId
|
|
363
|
-
* @returns {string} message body text
|
|
364
|
-
*/
|
|
365
|
-
export async function readImapMessage(account, messageId) {
|
|
366
|
-
const { host, port, user, pass, tls: useTLS } = account.imap;
|
|
367
|
-
const client = new IMAPClient(host, port || 993, user, pass, useTLS !== false);
|
|
368
|
-
try {
|
|
369
|
-
await client.connect();
|
|
370
|
-
await client._command('SELECT "INBOX"');
|
|
371
|
-
return await client.readMessage(messageId);
|
|
372
|
-
} finally {
|
|
373
|
-
await client.disconnect();
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Send email via SMTP.
|
|
379
|
-
* @param {object} account - { smtp: { host, port, user, pass, tls }, address }
|
|
380
|
-
* @param {string} to
|
|
381
|
-
* @param {string} subject
|
|
382
|
-
* @param {string} body
|
|
383
|
-
* @param {string} cc
|
|
384
|
-
* @param {string} bcc
|
|
385
|
-
*/
|
|
386
|
-
export async function sendSmtpEmail(account, to, subject, body, cc = '', bcc = '') {
|
|
387
|
-
const { host, port, user, pass, tls: useTLS } = account.smtp;
|
|
388
|
-
const from = account.address || user;
|
|
389
|
-
const client = new SMTPClient(host, port || 587, user, pass, useTLS === true || port === 465);
|
|
390
|
-
try {
|
|
391
|
-
await client.connect();
|
|
392
|
-
return await client.send(from, to, subject, body, cc, bcc);
|
|
393
|
-
} finally {
|
|
394
|
-
await client.disconnect();
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Get all configured email accounts from config.
|
|
400
|
-
* @param {object} config - NHA config
|
|
401
|
-
* @returns {Array<{ label, address, imap, smtp }>}
|
|
402
|
-
*/
|
|
403
|
-
export function getEmailAccounts(config) {
|
|
404
|
-
return config.emailAccounts || [];
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* List messages across ALL configured IMAP accounts.
|
|
409
|
-
* @param {object} config
|
|
410
|
-
* @param {number} limit - per account
|
|
411
|
-
* @returns {Array<{ account, messages[] }>}
|
|
412
|
-
*/
|
|
413
|
-
export async function listAllInboxes(config, limit = 10) {
|
|
414
|
-
const accounts = getEmailAccounts(config);
|
|
415
|
-
const results = [];
|
|
416
|
-
|
|
417
|
-
for (const account of accounts) {
|
|
418
|
-
if (!account.imap) continue;
|
|
419
|
-
try {
|
|
420
|
-
const messages = await listImapMessages(account, limit);
|
|
421
|
-
results.push({ account: account.label || account.address, messages });
|
|
422
|
-
} catch (e) {
|
|
423
|
-
results.push({ account: account.label || account.address, error: e.message, messages: [] });
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
return results;
|
|
428
|
-
}
|