nothumanallowed 12.6.6 → 12.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "12.6.6",
3
+ "version": "12.7.0",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 80 tools. Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -631,6 +631,14 @@ export async function cmdUI(args) {
631
631
  // GET /api/config — read config values for settings UI
632
632
  if (method === 'GET' && pathname === '/api/config') {
633
633
  // Return non-sensitive config for the settings form
634
+ // Sanitize email accounts — don't expose passwords to frontend
635
+ const safeAccounts = (config.emailAccounts || []).map(a => ({
636
+ label: a.label,
637
+ address: a.address,
638
+ isDefault: a.isDefault,
639
+ hasImap: !!(a.imap?.host),
640
+ hasSmtp: !!(a.smtp?.host),
641
+ }));
634
642
  sendJSON(res, 200, {
635
643
  profile: config.profile || {},
636
644
  provider: config.llm?.provider || '',
@@ -641,6 +649,8 @@ export async function cmdUI(args) {
641
649
  meetingAlert: config.ops?.meetingAlertMinutes || 30,
642
650
  hasTelegram: !!config.responder?.telegram?.token,
643
651
  hasDiscord: !!config.responder?.discord?.token,
652
+ hasGoogle: !!config._googleConnected,
653
+ emailAccounts: safeAccounts,
644
654
  });
645
655
  logRequest(method, pathname, 200, Date.now() - start);
646
656
  return;
package/src/config.mjs CHANGED
@@ -109,6 +109,16 @@ const DEFAULT_CONFIG = {
109
109
  slack: {
110
110
  token: '',
111
111
  },
112
+ emailAccounts: [
113
+ // Example:
114
+ // {
115
+ // label: 'Work',
116
+ // address: 'you@company.com',
117
+ // imap: { host: 'imap.company.com', port: 993, user: 'you@company.com', pass: '', tls: true },
118
+ // smtp: { host: 'smtp.company.com', port: 587, user: 'you@company.com', pass: '', tls: false },
119
+ // isDefault: false,
120
+ // }
121
+ ],
112
122
  profile: {
113
123
  name: '',
114
124
  email: '',
@@ -288,6 +298,42 @@ export function setConfigValue(key, value) {
288
298
  'language': 'language',
289
299
  };
290
300
 
301
+ // Special handler for email account management
302
+ if (key.startsWith('email-add')) {
303
+ // nha config set email-add "label|address|imap-host|imap-port|imap-user|imap-pass|smtp-host|smtp-port|smtp-user|smtp-pass"
304
+ const parts = value.split('|').map(p => p.trim());
305
+ if (parts.length < 6) return false;
306
+ const [label, address, imapHost, imapPort, imapUser, imapPass, smtpHost, smtpPort, smtpUser, smtpPass] = parts;
307
+ if (!config.emailAccounts) config.emailAccounts = [];
308
+ config.emailAccounts.push({
309
+ label: label || 'Email',
310
+ address: address || imapUser,
311
+ imap: { host: imapHost, port: parseInt(imapPort) || 993, user: imapUser, pass: imapPass, tls: true },
312
+ smtp: smtpHost ? { host: smtpHost, port: parseInt(smtpPort) || 587, user: smtpUser || imapUser, pass: smtpPass || imapPass, tls: false } : null,
313
+ isDefault: config.emailAccounts.length === 0,
314
+ });
315
+ saveConfig(config);
316
+ return true;
317
+ }
318
+
319
+ if (key === 'email-remove') {
320
+ if (!config.emailAccounts) return false;
321
+ const idx = parseInt(value);
322
+ if (isNaN(idx) || idx < 0 || idx >= config.emailAccounts.length) return false;
323
+ config.emailAccounts.splice(idx, 1);
324
+ saveConfig(config);
325
+ return true;
326
+ }
327
+
328
+ if (key === 'email-default') {
329
+ if (!config.emailAccounts) return false;
330
+ const idx = parseInt(value);
331
+ if (isNaN(idx) || idx < 0 || idx >= config.emailAccounts.length) return false;
332
+ config.emailAccounts.forEach((a, i) => a.isDefault = i === idx);
333
+ saveConfig(config);
334
+ return true;
335
+ }
336
+
291
337
  const resolved = aliases[key] || key;
292
338
  const resolvedParts = resolved.split('.');
293
339
 
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '12.6.6';
8
+ export const VERSION = '12.7.0';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -0,0 +1,428 @@
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
+ }
@@ -55,6 +55,8 @@ export const DESTRUCTIVE_ACTIONS = new Set([
55
55
  'gmail_send_attach',
56
56
  'gmail_reply',
57
57
  'gmail_delete',
58
+ 'email_send',
59
+ 'outreach_campaign',
58
60
  'calendar_create',
59
61
  'calendar_move',
60
62
  'calendar_update',
@@ -92,11 +94,42 @@ CRITICAL: Never output a JSON block as a "suggestion" or "let me try" — every
92
94
 
93
95
  TOOLS:
94
96
 
95
- --- EMAIL ---
97
+ --- EMAIL (Gmail) ---
96
98
 
97
99
  1. gmail_list(query: string, maxResults?: number)
98
- Search emails. query uses Gmail search syntax (e.g. "from:boss@co.com", "is:unread subject:invoice").
100
+ Search Gmail. query uses Gmail search syntax (e.g. "from:boss@co.com", "is:unread subject:invoice").
99
101
  Default maxResults = 10.
102
+ NOTE: For non-Gmail accounts use email_list instead.
103
+
104
+ --- EMAIL (Multi-Provider IMAP/SMTP) ---
105
+
106
+ email_accounts()
107
+ List all configured email accounts (Gmail + IMAP/SMTP).
108
+
109
+ email_list(account?: string, limit?: number)
110
+ List recent messages from an IMAP email account. If account not specified, lists all accounts.
111
+ account can be the label ("Work") or address ("you@company.com") or index (1, 2, 3).
112
+ Read-only: NEVER deletes or modifies messages on the server.
113
+
114
+ email_read(messageId: string, account?: string)
115
+ Read a single message by ID from an IMAP account.
116
+
117
+ email_send(to: string, subject: string, body: string, from?: string, cc?: string, bcc?: string)
118
+ Send email via SMTP. If 'from' specified, uses that account's SMTP. Otherwise uses default account.
119
+ ALWAYS confirm with the user before sending.
120
+
121
+ find_contact_email(url: string)
122
+ Scrape a website to find contact email addresses. Checks /contact, /about, /contatti, home page.
123
+ Returns list of email addresses found.
124
+
125
+ outreach_campaign(query: string, subject: string, template: string, maxSites?: number)
126
+ Full market research pipeline: search → find contact emails → prepare campaign.
127
+ 1. Searches the web for businesses matching the query
128
+ 2. Visits each site's contact page to extract emails
129
+ 3. Returns a report with all found emails + template preview
130
+ Does NOT send automatically — reports findings and waits for user confirmation.
131
+
132
+ --- EMAIL (Gmail) continued ---
100
133
 
101
134
  2. gmail_read(messageId: string)
102
135
  Read the full body of an email by its ID (returned by gmail_list).
@@ -820,6 +853,149 @@ export async function executeTool(action, params, config) {
820
853
  return `Email sent to ${params.to} with attachment "${downloaded.name}" (${formatFileSize(downloaded.size)}).`;
821
854
  }
822
855
 
856
+ // ── IMAP/SMTP Multi-Provider Email ────────────────────────────────────
857
+ case 'email_accounts': {
858
+ const { getEmailAccounts } = await import('./imap-email.mjs');
859
+ const accounts = getEmailAccounts(config);
860
+ if (accounts.length === 0) return 'No email accounts configured. Use "nha config set email-add" to add one, or configure in Settings.';
861
+ return accounts.map((a, i) => `${i + 1}. ${a.label || 'Email'} <${a.address}>${a.isDefault ? ' (default)' : ''}`).join('\n');
862
+ }
863
+
864
+ case 'email_list': {
865
+ const { listAllInboxes, listImapMessages, getEmailAccounts } = await import('./imap-email.mjs');
866
+ const accounts = getEmailAccounts(config);
867
+ if (accounts.length === 0) return 'No email accounts configured.';
868
+
869
+ // If account specified, use it; otherwise list all
870
+ if (params.account) {
871
+ const acct = accounts.find(a => a.label === params.account || a.address === params.account) || accounts[parseInt(params.account) - 1];
872
+ if (!acct) return `Account "${params.account}" not found.`;
873
+ const msgs = await listImapMessages(acct, params.limit || 10);
874
+ return msgs.length === 0 ? `No messages in ${acct.label || acct.address}.`
875
+ : `${acct.label || acct.address} (${msgs.length} messages):\n\n` + msgs.map((m, i) =>
876
+ `${i + 1}. ${m.seen ? '' : '[NEW] '}${m.from}\n ${m.subject}\n ${m.date}`).join('\n\n');
877
+ }
878
+
879
+ const all = await listAllInboxes(config, params.limit || 10);
880
+ return all.map(a => {
881
+ if (a.error) return `${a.account}: Error — ${a.error}`;
882
+ if (a.messages.length === 0) return `${a.account}: No messages.`;
883
+ return `${a.account} (${a.messages.length}):\n` + a.messages.map((m, i) =>
884
+ ` ${i + 1}. ${m.seen ? '' : '[NEW] '}${m.from} — ${m.subject}`).join('\n');
885
+ }).join('\n\n');
886
+ }
887
+
888
+ case 'email_read': {
889
+ const { readImapMessage, getEmailAccounts } = await import('./imap-email.mjs');
890
+ const accounts = getEmailAccounts(config);
891
+ if (!params.messageId) return 'messageId required.';
892
+ const acct = params.account
893
+ ? accounts.find(a => a.label === params.account || a.address === params.account) || accounts[0]
894
+ : accounts[0];
895
+ if (!acct) return 'No email accounts configured.';
896
+ const body = await readImapMessage(acct, params.messageId);
897
+ return body || 'Empty message.';
898
+ }
899
+
900
+ case 'email_send': {
901
+ const { sendSmtpEmail, getEmailAccounts } = await import('./imap-email.mjs');
902
+ const accounts = getEmailAccounts(config);
903
+ if (!params.to || !params.subject) return 'to and subject required.';
904
+
905
+ // Find the account to send from
906
+ let acct;
907
+ if (params.from) {
908
+ acct = accounts.find(a => a.address === params.from || a.label === params.from);
909
+ }
910
+ if (!acct) acct = accounts.find(a => a.isDefault) || accounts[0];
911
+ if (!acct || !acct.smtp) return 'No SMTP account configured for sending.';
912
+
913
+ const result = await sendSmtpEmail(acct, params.to, params.subject, params.body || '', params.cc || '', params.bcc || '');
914
+ return result.success ? `Email sent from ${acct.address} to ${params.to}.` : 'Send failed.';
915
+ }
916
+
917
+ case 'find_contact_email': {
918
+ // Scrape a website's contact/about page to find email addresses
919
+ const url = params.url || params.website;
920
+ if (!url) return 'url required.';
921
+
922
+ const fetchModule = await import('./web-tools.mjs');
923
+ const contactPaths = ['/contact', '/contacts', '/contatti', '/about', '/about-us', '/chi-siamo', '/impressum', '/kontakt', ''];
924
+ const emails = new Set();
925
+
926
+ for (const contactPath of contactPaths) {
927
+ try {
928
+ const fullUrl = url.replace(/\/+$/, '') + contactPath;
929
+ const result = await fetchModule.fetchUrl(config, fullUrl);
930
+ if (result.error) continue;
931
+ const text = result.text || result.content || '';
932
+ // Extract emails with regex
933
+ const found = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [];
934
+ found.forEach(e => {
935
+ const lower = e.toLowerCase();
936
+ // Filter out image/asset emails and common fake ones
937
+ if (!lower.endsWith('.png') && !lower.endsWith('.jpg') && !lower.endsWith('.gif')
938
+ && !lower.includes('example.com') && !lower.includes('sentry.io')
939
+ && !lower.includes('webpack') && !lower.includes('wixpress')) {
940
+ emails.add(lower);
941
+ }
942
+ });
943
+ if (emails.size > 0) break; // Found emails, stop searching
944
+ } catch { /* skip failed pages */ }
945
+ }
946
+
947
+ if (emails.size === 0) return `No email addresses found on ${url} (checked contact, about, home pages).`;
948
+ return `Found ${emails.size} email(s) on ${url}:\n${[...emails].join('\n')}`;
949
+ }
950
+
951
+ case 'outreach_campaign': {
952
+ // Full pipeline: search → find emails → send template
953
+ const query = params.query || params.search;
954
+ const template = params.template || params.body;
955
+ const subject = params.subject || 'Introduction';
956
+ if (!query) return 'query required (e.g., "Italian valve manufacturers").';
957
+ if (!template) return 'template required (email body text).';
958
+
959
+ // Step 1: Web search
960
+ const fetchModule = await import('./web-tools.mjs');
961
+ const searchResult = await fetchModule.webSearch(config, query, 10);
962
+ const urls = (searchResult.results || []).map(r => r.url).filter(Boolean).slice(0, params.maxSites || 5);
963
+
964
+ if (urls.length === 0) return 'No results found for the search query.';
965
+
966
+ // Step 2: Find emails from each site
967
+ const found = [];
968
+ for (const siteUrl of urls) {
969
+ try {
970
+ const contactPaths = ['/contact', '/contacts', '/contatti', '/about', ''];
971
+ for (const cp of contactPaths) {
972
+ const fullUrl = siteUrl.replace(/\/+$/, '') + cp;
973
+ const result = await fetchModule.fetchUrl(config, fullUrl);
974
+ const text = result.text || result.content || '';
975
+ const emails = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [];
976
+ const valid = emails.filter(e => !e.endsWith('.png') && !e.includes('example.com')).map(e => e.toLowerCase());
977
+ if (valid.length > 0) {
978
+ found.push({ site: siteUrl, emails: [...new Set(valid)] });
979
+ break;
980
+ }
981
+ }
982
+ } catch { /* skip */ }
983
+ }
984
+
985
+ if (found.length === 0) return `Searched ${urls.length} sites but found no contact emails. Try a different search query.`;
986
+
987
+ // Step 3: Report (don't send automatically — user must confirm)
988
+ let report = `Found ${found.length} sites with contact emails:\n\n`;
989
+ found.forEach((f, i) => {
990
+ report += `${i + 1}. ${f.site}\n ${f.emails.join(', ')}\n`;
991
+ });
992
+ report += `\nSubject: "${subject}"\nTemplate preview: ${template.slice(0, 200)}...\n`;
993
+ report += `\nTo send to all, reply: "send the campaign"`;
994
+ report += `\nTo send to specific ones, reply: "send to #1 and #3"`;
995
+
996
+ return report;
997
+ }
998
+
823
999
  // ── Calendar ──────────────────────────────────────────────────────────
824
1000
  case 'calendar_today': {
825
1001
  const events = await getTodayEvents(config);
@@ -2321,6 +2321,7 @@ function renderSettings(el) {
2321
2321
  ['summary-time', 'Summary Time', '18:00'],
2322
2322
  ['meeting-alert', 'Meeting Alert (minutes)', '30'],
2323
2323
  ]) +
2324
+ renderEmailAccountsSection() +
2324
2325
  '<div class="card" style="margin-top:16px"><div class="card__title">Google Account</div>' +
2325
2326
  '<div style="font-size:11px;color:var(--dim);margin-bottom:8px">Connect Gmail, Calendar, Drive, Contacts, and Tasks. Opens a browser window for Google sign-in.</div>' +
2326
2327
  (settingsData.hasGoogle ? '<div style="color:var(--green);font-size:12px;margin-bottom:8px">\\u2705 Connected</div>' : '') +
@@ -2341,6 +2342,92 @@ function connectGoogle() {
2341
2342
  });
2342
2343
  }
2343
2344
 
2345
+ function renderEmailAccountsSection() {
2346
+ var accounts = settingsData.emailAccounts || [];
2347
+ var h = '<div class="card" style="margin-bottom:16px"><div class="card__title" style="color:var(--green);font-size:14px;margin-bottom:4px">Email Accounts (IMAP/SMTP)</div>';
2348
+ h += '<div style="font-size:11px;color:var(--dim);margin-bottom:12px">Add email accounts for multi-provider inbox. Read-only IMAP (never deletes from server) + SMTP for sending. Works alongside Gmail.</div>';
2349
+
2350
+ if (accounts.length > 0) {
2351
+ for (var i = 0; i < accounts.length; i++) {
2352
+ var a = accounts[i];
2353
+ h += '<div style="padding:8px 12px;margin-bottom:8px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;display:flex;align-items:center;justify-content:space-between">';
2354
+ h += '<div><span style="color:var(--green);font-weight:700;font-size:12px">' + esc(a.label || 'Email') + '</span>';
2355
+ h += '<span style="color:var(--dim);font-size:11px;margin-left:8px">' + esc(a.address || '') + '</span>';
2356
+ if (a.isDefault) h += '<span style="color:var(--amber);font-size:9px;margin-left:6px">(default)</span>';
2357
+ h += '</div>';
2358
+ h += '<div><button onclick="setDefaultEmail(' + i + ')" style="background:none;border:1px solid var(--border);color:var(--dim);padding:3px 8px;border-radius:4px;cursor:pointer;font-size:10px;margin-right:4px" title="Set as default">Default</button>';
2359
+ h += '<button onclick="removeEmailAccount(' + i + ')" style="background:none;border:1px solid var(--red3);color:var(--red);padding:3px 8px;border-radius:4px;cursor:pointer;font-size:10px" title="Remove">Remove</button></div></div>';
2360
+ }
2361
+ } else {
2362
+ h += '<div style="color:var(--dim);font-size:11px;padding:8px 0">No email accounts configured.</div>';
2363
+ }
2364
+
2365
+ h += '<div style="margin-top:12px;border-top:1px solid var(--border);padding-top:12px">';
2366
+ h += '<div style="font-size:12px;color:var(--fg);font-weight:700;margin-bottom:8px">Add Email Account</div>';
2367
+ var fields = [
2368
+ ['ea_label', 'Label', 'e.g. Work, Personal'],
2369
+ ['ea_address', 'Email Address', 'e.g. you@company.com'],
2370
+ ['ea_imap_host', 'IMAP Host', 'e.g. imap.company.com'],
2371
+ ['ea_imap_port', 'IMAP Port', '993'],
2372
+ ['ea_imap_user', 'IMAP User', 'Usually your email address'],
2373
+ ['ea_imap_pass', 'IMAP Password', 'App password recommended', true],
2374
+ ['ea_smtp_host', 'SMTP Host', 'e.g. smtp.company.com'],
2375
+ ['ea_smtp_port', 'SMTP Port', '587'],
2376
+ ['ea_smtp_user', 'SMTP User', 'Usually same as IMAP'],
2377
+ ['ea_smtp_pass', 'SMTP Password', 'Usually same as IMAP', true],
2378
+ ];
2379
+ for (var i = 0; i < fields.length; i++) {
2380
+ var f = fields[i];
2381
+ h += '<div style="margin-bottom:6px"><label style="display:block;font-size:10px;color:var(--dim);margin-bottom:2px">' + esc(f[1]) + '</label>';
2382
+ h += '<input id="' + f[0] + '" type="' + (f[3] ? 'password' : 'text') + '" placeholder="' + esc(f[2]) + '" style="width:100%;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-size:12px;font-family:var(--mono)"></div>';
2383
+ }
2384
+ h += '<button onclick="addEmailAccount()" style="margin-top:8px;padding:8px 20px;background:var(--green3);color:var(--bg);border:none;border-radius:6px;cursor:pointer;font-weight:700;font-size:12px">Add Account</button>';
2385
+ h += '<div id="emailAccountStatus" style="margin-top:6px;font-size:10px;color:var(--dim)"></div>';
2386
+ h += '</div></div>';
2387
+ return h;
2388
+ }
2389
+
2390
+ function addEmailAccount() {
2391
+ var label = document.getElementById('ea_label').value.trim();
2392
+ var address = document.getElementById('ea_address').value.trim();
2393
+ var imapHost = document.getElementById('ea_imap_host').value.trim();
2394
+ var imapPort = document.getElementById('ea_imap_port').value.trim() || '993';
2395
+ var imapUser = document.getElementById('ea_imap_user').value.trim() || address;
2396
+ var imapPass = document.getElementById('ea_imap_pass').value;
2397
+ var smtpHost = document.getElementById('ea_smtp_host').value.trim();
2398
+ var smtpPort = document.getElementById('ea_smtp_port').value.trim() || '587';
2399
+ var smtpUser = document.getElementById('ea_smtp_user').value.trim() || imapUser;
2400
+ var smtpPass = document.getElementById('ea_smtp_pass').value || imapPass;
2401
+
2402
+ if (!address || !imapHost) {
2403
+ var s = document.getElementById('emailAccountStatus');
2404
+ if (s) { s.textContent = 'Email address and IMAP host are required.'; s.style.color = 'var(--red)'; }
2405
+ return;
2406
+ }
2407
+
2408
+ var val = [label, address, imapHost, imapPort, imapUser, imapPass, smtpHost, smtpPort, smtpUser, smtpPass].join('|');
2409
+ apiPost('/api/config', { key: 'email-add', value: val }).then(function() {
2410
+ var s = document.getElementById('emailAccountStatus');
2411
+ if (s) { s.textContent = 'Account added!'; s.style.color = 'var(--green)'; }
2412
+ settingsLoaded = false;
2413
+ renderSettings(document.getElementById('mainContent'));
2414
+ });
2415
+ }
2416
+
2417
+ function removeEmailAccount(idx) {
2418
+ apiPost('/api/config', { key: 'email-remove', value: String(idx) }).then(function() {
2419
+ settingsLoaded = false;
2420
+ renderSettings(document.getElementById('mainContent'));
2421
+ });
2422
+ }
2423
+
2424
+ function setDefaultEmail(idx) {
2425
+ apiPost('/api/config', { key: 'email-default', value: String(idx) }).then(function() {
2426
+ settingsLoaded = false;
2427
+ renderSettings(document.getElementById('mainContent'));
2428
+ });
2429
+ }
2430
+
2344
2431
  function settingsSection(id, title, desc, fields) {
2345
2432
  var h = '<form class="card" style="margin-bottom:16px" id="settings-' + id + '" onsubmit="event.preventDefault();saveSettingsSection(\\x27' + id + '\\x27)">' +
2346
2433
  '<div class="card__title" style="color:var(--green);font-size:14px;margin-bottom:4px">' + esc(title) + '</div>' +