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
package/src/config.mjs
CHANGED
|
@@ -109,16 +109,6 @@ 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
|
-
],
|
|
122
112
|
profile: {
|
|
123
113
|
name: '',
|
|
124
114
|
email: '',
|
|
@@ -298,42 +288,6 @@ export function setConfigValue(key, value) {
|
|
|
298
288
|
'language': 'language',
|
|
299
289
|
};
|
|
300
290
|
|
|
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
|
-
|
|
337
291
|
const resolved = aliases[key] || key;
|
|
338
292
|
const resolvedParts = resolved.split('.');
|
|
339
293
|
|
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 = '
|
|
8
|
+
export const VERSION = '13.2.13';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -44,14 +44,9 @@ function generatePKCE() {
|
|
|
44
44
|
function openBrowser(url) {
|
|
45
45
|
const platform = os.platform();
|
|
46
46
|
try {
|
|
47
|
-
if (platform === 'darwin') {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// Use rundll32 — more reliable than 'start' in batch/npm contexts
|
|
51
|
-
execSync(`rundll32 url.dll,FileProtocolHandler "${url}"`);
|
|
52
|
-
} else {
|
|
53
|
-
execSync(`xdg-open "${url}"`);
|
|
54
|
-
}
|
|
47
|
+
if (platform === 'darwin') execSync(`open "${url}"`);
|
|
48
|
+
else if (platform === 'win32') execSync(`start "" "${url}"`);
|
|
49
|
+
else execSync(`xdg-open "${url}"`);
|
|
55
50
|
} catch {
|
|
56
51
|
warn('Could not open browser automatically.');
|
|
57
52
|
info(`Open this URL manually:\n\n ${url}\n`);
|
package/src/services/llm.mjs
CHANGED
|
@@ -252,12 +252,24 @@ export async function callNHA(apiKey, model, systemPrompt, userMessage, stream =
|
|
|
252
252
|
}
|
|
253
253
|
} catch {}
|
|
254
254
|
|
|
255
|
+
// Sanitize content before sending through SENTINEL — strip patterns that trigger WAF
|
|
256
|
+
// (backticks, template literals, SSTI patterns) without affecting semantics
|
|
257
|
+
const sanitizeForSentinel = (s) => String(s || '')
|
|
258
|
+
.replace(/`/g, "'") // backtick → single quote
|
|
259
|
+
.replace(/\$\{([^}]*)\}/g, '[$1]') // ${expr} → [expr]
|
|
260
|
+
.replace(/\{\{([^}]*)\}\}/g, '{$1}') // {{expr}} → {expr}
|
|
261
|
+
.replace(/\{%([^%]*)%\}/g, '{$1}') // {% expr %} → { expr }
|
|
262
|
+
.replace(/<!ENTITY/gi, '<!ENTITY') // XXE
|
|
263
|
+
.replace(/SYSTEM\s+["']/gi, 'SYSTEM ') // XXE SYSTEM
|
|
264
|
+
.replace(/\|\|\(/g, '||(') // LDAP (cosmetic, non-breaking)
|
|
265
|
+
.replace(/\)\|\|/g, ')||'); // LDAP
|
|
266
|
+
|
|
255
267
|
const body = {
|
|
256
268
|
model: model || '/opt/models/qwen3-32b',
|
|
257
269
|
max_tokens: thinkingEnabled ? 8192 : 4096,
|
|
258
270
|
messages: [
|
|
259
|
-
{ role: 'system', content: systemPrompt },
|
|
260
|
-
{ role: 'user', content: userMessage },
|
|
271
|
+
{ role: 'system', content: sanitizeForSentinel(systemPrompt) },
|
|
272
|
+
{ role: 'user', content: sanitizeForSentinel(userMessage) },
|
|
261
273
|
],
|
|
262
274
|
stream,
|
|
263
275
|
chat_template_kwargs: { enable_thinking: thinkingEnabled },
|
|
@@ -318,7 +330,7 @@ export function getApiKey(config, provider) {
|
|
|
318
330
|
* @returns {Promise<string>} The LLM response text.
|
|
319
331
|
*/
|
|
320
332
|
export async function callLLM(config, systemPrompt, userMessage, opts = {}) {
|
|
321
|
-
const provider = opts.provider || config.llm.provider || 'anthropic';
|
|
333
|
+
const provider = opts.provider || config.llm.provider || (config.llm.apiKey ? 'anthropic' : 'nha');
|
|
322
334
|
const model = opts.model || config.llm.model || null;
|
|
323
335
|
const apiKey = getApiKey(config, provider);
|
|
324
336
|
if (!apiKey) throw new Error(`No API key for ${provider}`);
|
|
@@ -443,7 +455,7 @@ export async function callLLMVision(config, systemPrompt, userMessage, media) {
|
|
|
443
455
|
* @returns {Promise<string>} The full LLM response text.
|
|
444
456
|
*/
|
|
445
457
|
export async function callLLMStream(config, systemPrompt, userMessage, onToken, opts = {}) {
|
|
446
|
-
const provider = opts.provider || config.llm.provider || 'anthropic';
|
|
458
|
+
const provider = opts.provider || config.llm.provider || (config.llm.apiKey ? 'anthropic' : 'nha');
|
|
447
459
|
const model = opts.model || config.llm.model || null;
|
|
448
460
|
const apiKey = getApiKey(config, provider);
|
|
449
461
|
if (!apiKey) throw new Error(`No API key for ${provider}`);
|
|
@@ -474,6 +486,59 @@ export async function callLLMStream(config, systemPrompt, userMessage, onToken,
|
|
|
474
486
|
return text;
|
|
475
487
|
}
|
|
476
488
|
|
|
489
|
+
// NHA Free tier: delegate entirely to callNHA which handles sanitization,
|
|
490
|
+
// thinking config, and the proxy correctly — then wrap with callback
|
|
491
|
+
if (provider === 'nha') {
|
|
492
|
+
// callNHA with stream=true returns the streamSSE result (async iterable/text)
|
|
493
|
+
// We need callback-based streaming, so use streamSSEWithCallback directly
|
|
494
|
+
// after building the sanitized body ourselves
|
|
495
|
+
const sanitize = (s) => String(s || '')
|
|
496
|
+
.replace(/`/g, "'")
|
|
497
|
+
.replace(/\$\{([^}]*)\}/g, '[$1]')
|
|
498
|
+
.replace(/\{\{([^}]*)\}\}/g, '{$1}')
|
|
499
|
+
.replace(/\{%([^%]*)%\}/g, '{$1}')
|
|
500
|
+
.replace(/<!ENTITY/gi, '<!ENTITY')
|
|
501
|
+
.replace(/SYSTEM\s+["']/gi, 'SYSTEM ')
|
|
502
|
+
.replace(/\|\|\(/g, '||(')
|
|
503
|
+
.replace(/\)\|\|/g, ')||');
|
|
504
|
+
|
|
505
|
+
let thinkingEnabled = false;
|
|
506
|
+
try {
|
|
507
|
+
const fs2 = await import('fs');
|
|
508
|
+
const path2 = await import('path');
|
|
509
|
+
const os2 = await import('os');
|
|
510
|
+
const cfgFile2 = path2.default.join(os2.default.homedir(), '.nha', 'config.json');
|
|
511
|
+
if (fs2.default.existsSync(cfgFile2)) {
|
|
512
|
+
const cfg2 = JSON.parse(fs2.default.readFileSync(cfgFile2, 'utf-8'));
|
|
513
|
+
thinkingEnabled = cfg2.thinking === true || cfg2.thinking === 'on' || cfg2.thinking === 'true';
|
|
514
|
+
}
|
|
515
|
+
} catch {}
|
|
516
|
+
|
|
517
|
+
const nhaBody = {
|
|
518
|
+
model: model || '/opt/models/qwen3-32b',
|
|
519
|
+
max_tokens: thinkingEnabled ? 8192 : 4096,
|
|
520
|
+
messages: [
|
|
521
|
+
{ role: 'system', content: sanitize(systemPrompt) },
|
|
522
|
+
{ role: 'user', content: sanitize(userMessage) },
|
|
523
|
+
],
|
|
524
|
+
stream: true,
|
|
525
|
+
chat_template_kwargs: { enable_thinking: thinkingEnabled },
|
|
526
|
+
};
|
|
527
|
+
const nhaRes = await fetch('https://nothumanallowed.com/api/v1/liara/chat', {
|
|
528
|
+
method: 'POST',
|
|
529
|
+
headers: { 'Content-Type': 'application/json' },
|
|
530
|
+
body: JSON.stringify(nhaBody),
|
|
531
|
+
});
|
|
532
|
+
if (!nhaRes.ok) {
|
|
533
|
+
const err = await nhaRes.text();
|
|
534
|
+
throw new Error(`NHA Free ${nhaRes.status}: ${err}`);
|
|
535
|
+
}
|
|
536
|
+
// Node.js native fetch ReadableStream closes after first TCP buffer for SSE.
|
|
537
|
+
// Use res.text() to get the full response, then parse SSE lines synchronously.
|
|
538
|
+
const rawText = await nhaRes.text();
|
|
539
|
+
return parseSSEText(rawText, 'openai', onToken);
|
|
540
|
+
}
|
|
541
|
+
|
|
477
542
|
const format = provider === 'anthropic' ? 'anthropic' : 'openai';
|
|
478
543
|
const body = buildRequestBody(provider, model, systemPrompt, userMessage, true);
|
|
479
544
|
const url = getProviderUrl(provider, model, apiKey);
|
|
@@ -558,12 +623,62 @@ function getProviderHeaders(provider, apiKey) {
|
|
|
558
623
|
};
|
|
559
624
|
}
|
|
560
625
|
|
|
626
|
+
/** Parse a complete SSE text body (already read via res.text()) and call onToken per token. */
|
|
627
|
+
function parseSSEText(text, format, onToken) {
|
|
628
|
+
let fullText = '';
|
|
629
|
+
let thinkBuf = '';
|
|
630
|
+
let inThink = false;
|
|
631
|
+
|
|
632
|
+
for (const line of text.split('\n')) {
|
|
633
|
+
if (!line.startsWith('data: ')) continue;
|
|
634
|
+
const data = line.slice(6).trim();
|
|
635
|
+
if (data === '[DONE]') continue;
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
const json = JSON.parse(data);
|
|
639
|
+
let chunk = '';
|
|
640
|
+
if (format === 'anthropic') {
|
|
641
|
+
if (json.type === 'content_block_delta') chunk = json.delta?.text || '';
|
|
642
|
+
} else {
|
|
643
|
+
chunk = json.choices?.[0]?.delta?.content || '';
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (chunk) {
|
|
647
|
+
thinkBuf += chunk;
|
|
648
|
+
let out = '';
|
|
649
|
+
while (thinkBuf.length > 0) {
|
|
650
|
+
if (inThink) {
|
|
651
|
+
const end = thinkBuf.indexOf('</think>');
|
|
652
|
+
if (end === -1) { thinkBuf = ''; break; }
|
|
653
|
+
inThink = false;
|
|
654
|
+
thinkBuf = thinkBuf.slice(end + 8);
|
|
655
|
+
} else {
|
|
656
|
+
const start = thinkBuf.indexOf('<think>');
|
|
657
|
+
if (start === -1) { out += thinkBuf; thinkBuf = ''; break; }
|
|
658
|
+
out += thinkBuf.slice(0, start);
|
|
659
|
+
inThink = true;
|
|
660
|
+
thinkBuf = thinkBuf.slice(start + 7);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (out) {
|
|
664
|
+
fullText += out;
|
|
665
|
+
if (onToken) onToken(out);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
} catch {}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return fullText;
|
|
672
|
+
}
|
|
673
|
+
|
|
561
674
|
/** SSE stream parser with onToken callback (does NOT write to stdout directly) */
|
|
562
675
|
async function streamSSEWithCallback(res, format, onToken) {
|
|
563
676
|
const reader = res.body.getReader();
|
|
564
677
|
const decoder = new TextDecoder();
|
|
565
678
|
let buffer = '';
|
|
566
679
|
let fullText = '';
|
|
680
|
+
let thinkBuf = ''; // accumulates <think>...</think> content to suppress
|
|
681
|
+
let inThink = false;
|
|
567
682
|
|
|
568
683
|
while (true) {
|
|
569
684
|
const { done, value } = await reader.read();
|
|
@@ -591,8 +706,27 @@ async function streamSSEWithCallback(res, format, onToken) {
|
|
|
591
706
|
}
|
|
592
707
|
|
|
593
708
|
if (chunk) {
|
|
594
|
-
|
|
595
|
-
|
|
709
|
+
// Filter out <think>...</think> blocks from Qwen3 thinking mode
|
|
710
|
+
thinkBuf += chunk;
|
|
711
|
+
let out = '';
|
|
712
|
+
while (thinkBuf.length > 0) {
|
|
713
|
+
if (inThink) {
|
|
714
|
+
const end = thinkBuf.indexOf('</think>');
|
|
715
|
+
if (end === -1) { thinkBuf = ''; break; } // still inside think block
|
|
716
|
+
inThink = false;
|
|
717
|
+
thinkBuf = thinkBuf.slice(end + 8);
|
|
718
|
+
} else {
|
|
719
|
+
const start = thinkBuf.indexOf('<think>');
|
|
720
|
+
if (start === -1) { out += thinkBuf; thinkBuf = ''; break; }
|
|
721
|
+
out += thinkBuf.slice(0, start);
|
|
722
|
+
inThink = true;
|
|
723
|
+
thinkBuf = thinkBuf.slice(start + 7);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (out) {
|
|
727
|
+
fullText += out;
|
|
728
|
+
if (onToken) onToken(out);
|
|
729
|
+
}
|
|
596
730
|
}
|
|
597
731
|
} catch {}
|
|
598
732
|
}
|
|
@@ -55,8 +55,6 @@ 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',
|
|
60
58
|
'calendar_create',
|
|
61
59
|
'calendar_move',
|
|
62
60
|
'calendar_update',
|
|
@@ -94,42 +92,11 @@ CRITICAL: Never output a JSON block as a "suggestion" or "let me try" — every
|
|
|
94
92
|
|
|
95
93
|
TOOLS:
|
|
96
94
|
|
|
97
|
-
--- EMAIL
|
|
95
|
+
--- EMAIL ---
|
|
98
96
|
|
|
99
97
|
1. gmail_list(query: string, maxResults?: number)
|
|
100
|
-
Search
|
|
98
|
+
Search emails. query uses Gmail search syntax (e.g. "from:boss@co.com", "is:unread subject:invoice").
|
|
101
99
|
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 ---
|
|
133
100
|
|
|
134
101
|
2. gmail_read(messageId: string)
|
|
135
102
|
Read the full body of an email by its ID (returned by gmail_list).
|
|
@@ -853,149 +820,6 @@ export async function executeTool(action, params, config) {
|
|
|
853
820
|
return `Email sent to ${params.to} with attachment "${downloaded.name}" (${formatFileSize(downloaded.size)}).`;
|
|
854
821
|
}
|
|
855
822
|
|
|
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
|
-
|
|
999
823
|
// ── Calendar ──────────────────────────────────────────────────────────
|
|
1000
824
|
case 'calendar_today': {
|
|
1001
825
|
const events = await getTodayEvents(config);
|