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.
@@ -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(/&nbsp;/g, ' ')
332
- .replace(/&amp;/g, '&')
333
- .replace(/&lt;/g, '<')
334
- .replace(/&gt;/g, '>')
335
- .replace(/&quot;/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
- }