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 +1 -1
- package/src/commands/ui.mjs +10 -0
- package/src/config.mjs +46 -0
- package/src/constants.mjs +1 -1
- package/src/services/imap-email.mjs +428 -0
- package/src/services/tool-executor.mjs +178 -2
- package/src/services/web-ui.mjs +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "12.
|
|
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": {
|
package/src/commands/ui.mjs
CHANGED
|
@@ -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.
|
|
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(/ /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
|
+
}
|
|
@@ -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
|
|
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);
|
package/src/services/web-ui.mjs
CHANGED
|
@@ -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>' +
|