neoagent 3.1.0 → 3.1.1-beta.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.
@@ -0,0 +1,993 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const db = require('../../../db/database');
5
+ const { resolveAgentId } = require('../../agents/manager');
6
+ const {
7
+ deleteProviderConfig,
8
+ getProviderConfig,
9
+ setProviderConfig,
10
+ } = require('../provider_config_store');
11
+ const { getConnectionAccessMode } = require('../access');
12
+ const { decryptValue } = require('../secrets');
13
+ const { appendQuery, fetchJson } = require('../oauth_provider');
14
+ const { resolvePublicBaseUrl } = require('../env');
15
+
16
+ const NEOMAIL_PROVIDER_KEY = 'neomail';
17
+ const NEOMAIL_APP = Object.freeze({
18
+ id: 'mailbox',
19
+ label: 'Mailbox',
20
+ description:
21
+ 'Search threads, read mail, draft replies, send messages, and trigger tasks from a connected NeoMail inbox.',
22
+ });
23
+ const NEOMAIL_COMPANION_SCOPES = Object.freeze([
24
+ 'mail:read',
25
+ 'mail:write',
26
+ 'drafts:write',
27
+ 'send:write',
28
+ 'ai:use',
29
+ ]);
30
+ const TOOL_DEFINITIONS = Object.freeze([
31
+ {
32
+ appId: NEOMAIL_APP.id,
33
+ name: 'neomail_list_accounts',
34
+ access: 'read',
35
+ description: 'List mailboxes available in the connected NeoMail account.',
36
+ parameters: { type: 'object', properties: {} },
37
+ },
38
+ {
39
+ appId: NEOMAIL_APP.id,
40
+ name: 'neomail_list_threads',
41
+ access: 'read',
42
+ description: 'List threads from the connected NeoMail account.',
43
+ parameters: {
44
+ type: 'object',
45
+ properties: {
46
+ mail_account_id: {
47
+ type: 'string',
48
+ description: 'Optional NeoMail mailbox account ID.',
49
+ },
50
+ mail_account_email: {
51
+ type: 'string',
52
+ description: 'Optional NeoMail mailbox email address.',
53
+ },
54
+ folder: { type: 'string', description: 'Optional folder path like INBOX or archive.' },
55
+ query: { type: 'string', description: 'Optional text query.' },
56
+ unread_only: { type: 'boolean', description: 'Whether to return only unread threads.' },
57
+ limit: { type: 'number', description: 'Maximum threads to return, default 25.' },
58
+ },
59
+ },
60
+ },
61
+ {
62
+ appId: NEOMAIL_APP.id,
63
+ name: 'neomail_get_thread',
64
+ access: 'read',
65
+ description: 'Read a full thread from NeoMail.',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ thread_id: { type: 'string', description: 'NeoMail thread ID.' },
70
+ },
71
+ required: ['thread_id'],
72
+ },
73
+ },
74
+ {
75
+ appId: NEOMAIL_APP.id,
76
+ name: 'neomail_search_messages',
77
+ access: 'read',
78
+ description: 'Search messages in NeoMail.',
79
+ parameters: {
80
+ type: 'object',
81
+ properties: {
82
+ query: { type: 'string', description: 'Search query.' },
83
+ mail_account_id: {
84
+ type: 'string',
85
+ description: 'Optional NeoMail mailbox account ID.',
86
+ },
87
+ mail_account_email: {
88
+ type: 'string',
89
+ description: 'Optional NeoMail mailbox email address.',
90
+ },
91
+ limit: { type: 'number', description: 'Maximum results to return, default 20.' },
92
+ },
93
+ required: ['query'],
94
+ },
95
+ },
96
+ {
97
+ appId: NEOMAIL_APP.id,
98
+ name: 'neomail_save_draft',
99
+ access: 'write',
100
+ description: 'Create or update a NeoMail draft.',
101
+ parameters: {
102
+ type: 'object',
103
+ properties: {
104
+ draft_id: { type: 'string', description: 'Optional existing draft ID.' },
105
+ mail_account_id: {
106
+ type: 'string',
107
+ description: 'Optional NeoMail mailbox account ID.',
108
+ },
109
+ mail_account_email: {
110
+ type: 'string',
111
+ description: 'Optional NeoMail mailbox email address.',
112
+ },
113
+ thread_id: { type: 'string', description: 'Optional NeoMail thread ID.' },
114
+ to: {
115
+ type: 'array',
116
+ items: {
117
+ anyOf: [
118
+ { type: 'string' },
119
+ {
120
+ type: 'object',
121
+ properties: {
122
+ name: { type: 'string' },
123
+ address: { type: 'string' },
124
+ },
125
+ required: ['address'],
126
+ },
127
+ ],
128
+ },
129
+ description: 'Recipient list.',
130
+ },
131
+ cc: {
132
+ type: 'array',
133
+ items: {
134
+ anyOf: [
135
+ { type: 'string' },
136
+ {
137
+ type: 'object',
138
+ properties: {
139
+ name: { type: 'string' },
140
+ address: { type: 'string' },
141
+ },
142
+ required: ['address'],
143
+ },
144
+ ],
145
+ },
146
+ },
147
+ bcc: {
148
+ type: 'array',
149
+ items: {
150
+ anyOf: [
151
+ { type: 'string' },
152
+ {
153
+ type: 'object',
154
+ properties: {
155
+ name: { type: 'string' },
156
+ address: { type: 'string' },
157
+ },
158
+ required: ['address'],
159
+ },
160
+ ],
161
+ },
162
+ },
163
+ subject: { type: 'string', description: 'Draft subject.' },
164
+ body_text: { type: 'string', description: 'Plain-text draft body.' },
165
+ body_html: { type: 'string', description: 'Optional HTML draft body.' },
166
+ scheduled_for: { type: 'string', description: 'Optional ISO datetime for send-later.' },
167
+ },
168
+ required: ['to'],
169
+ },
170
+ },
171
+ {
172
+ appId: NEOMAIL_APP.id,
173
+ name: 'neomail_send_draft',
174
+ access: 'write',
175
+ description: 'Send a NeoMail draft immediately or leave it queued.',
176
+ parameters: {
177
+ type: 'object',
178
+ properties: {
179
+ draft_id: { type: 'string', description: 'NeoMail draft ID.' },
180
+ immediate: { type: 'boolean', description: 'Whether to send immediately, default true.' },
181
+ },
182
+ required: ['draft_id'],
183
+ },
184
+ },
185
+ {
186
+ appId: NEOMAIL_APP.id,
187
+ name: 'neomail_update_thread',
188
+ access: 'write',
189
+ description: 'Update NeoMail thread flags and labels.',
190
+ parameters: {
191
+ type: 'object',
192
+ properties: {
193
+ thread_id: { type: 'string', description: 'NeoMail thread ID.' },
194
+ is_read: { type: 'boolean', description: 'Read state.' },
195
+ starred: { type: 'boolean', description: 'Starred state.' },
196
+ archived: { type: 'boolean', description: 'Archived state.' },
197
+ trashed: { type: 'boolean', description: 'Trash state.' },
198
+ labels: { type: 'array', items: { type: 'string' }, description: 'Optional labels.' },
199
+ },
200
+ required: ['thread_id'],
201
+ },
202
+ },
203
+ {
204
+ appId: NEOMAIL_APP.id,
205
+ name: 'neomail_ai_summarize_thread',
206
+ access: 'read',
207
+ description: 'Generate an AI summary for a NeoMail thread.',
208
+ parameters: {
209
+ type: 'object',
210
+ properties: {
211
+ thread_id: { type: 'string', description: 'NeoMail thread ID.' },
212
+ },
213
+ required: ['thread_id'],
214
+ },
215
+ },
216
+ {
217
+ appId: NEOMAIL_APP.id,
218
+ name: 'neomail_ai_improve_draft',
219
+ access: 'write',
220
+ description: 'Improve a NeoMail draft with NeoMail AI.',
221
+ parameters: {
222
+ type: 'object',
223
+ properties: {
224
+ draft_id: { type: 'string', description: 'NeoMail draft ID.' },
225
+ instruction: { type: 'string', description: 'Optional rewrite instruction.' },
226
+ },
227
+ required: ['draft_id'],
228
+ },
229
+ },
230
+ {
231
+ appId: NEOMAIL_APP.id,
232
+ name: 'neomail_ai_ask_inbox',
233
+ access: 'read',
234
+ description: 'Ask NeoMail AI a question about the inbox.',
235
+ parameters: {
236
+ type: 'object',
237
+ properties: {
238
+ query: { type: 'string', description: 'Inbox question.' },
239
+ },
240
+ required: ['query'],
241
+ },
242
+ },
243
+ ]);
244
+
245
+ const toolAppMap = new Map(TOOL_DEFINITIONS.map((tool) => [tool.name, tool.appId]));
246
+
247
+ function trimText(value) {
248
+ return String(value || '').trim();
249
+ }
250
+
251
+ function isPrivateHost(hostname) {
252
+ const lower = String(hostname || '').trim().toLowerCase();
253
+ if (!lower) return false;
254
+ return lower === 'localhost'
255
+ || lower === '::1'
256
+ || lower === '[::1]'
257
+ || lower.startsWith('127.')
258
+ || lower.startsWith('10.')
259
+ || lower.startsWith('192.168.')
260
+ || /^172\.(1[6-9]|2\d|3[0-1])\./.test(lower);
261
+ }
262
+
263
+ function normalizeNeoMailBaseUrl(value) {
264
+ const raw = trimText(value);
265
+ if (!raw) {
266
+ throw new Error('NeoMail backend URL is required.');
267
+ }
268
+ const withScheme = raw.includes('://')
269
+ ? raw
270
+ : `${isPrivateHost(raw.split('/')[0]) ? 'http' : 'https'}://${raw}`;
271
+ let parsed;
272
+ try {
273
+ parsed = new URL(withScheme);
274
+ } catch {
275
+ throw new Error('NeoMail backend URL must be a valid HTTP or HTTPS URL.');
276
+ }
277
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
278
+ throw new Error('NeoMail backend URL must use HTTP or HTTPS.');
279
+ }
280
+ if (parsed.hash) {
281
+ throw new Error('NeoMail backend URL must not contain a fragment.');
282
+ }
283
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '');
284
+ return parsed.toString().replace(/\/+$/, '');
285
+ }
286
+
287
+ function parseConfigInput(rawConfig, existingConfig = {}) {
288
+ const source = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};
289
+ return {
290
+ baseUrl: trimText(source.baseUrl) || trimText(existingConfig.baseUrl),
291
+ };
292
+ }
293
+
294
+ function getCallbackUrl() {
295
+ return `${resolvePublicBaseUrl()}/api/integrations/oauth/callback`;
296
+ }
297
+
298
+ function getCompanionBootstrapUrl(baseUrl) {
299
+ return `${baseUrl}/api/oauth/companion/neoagent/bootstrap`;
300
+ }
301
+
302
+ async function fetchNeoMailAuthStatus(baseUrl) {
303
+ return fetchJson(
304
+ `${baseUrl}/api/auth/status`,
305
+ { method: 'GET' },
306
+ { serviceName: 'NeoMail auth status' },
307
+ );
308
+ }
309
+
310
+ async function bootstrapNeoMailCompanion(baseUrl) {
311
+ return fetchJson(
312
+ getCompanionBootstrapUrl(baseUrl),
313
+ {
314
+ method: 'POST',
315
+ json: {
316
+ redirectUri: getCallbackUrl(),
317
+ appName: 'NeoAgent',
318
+ },
319
+ },
320
+ { serviceName: 'NeoMail companion bootstrap' },
321
+ );
322
+ }
323
+
324
+ async function exchangeNeoMailToken(baseUrl, form) {
325
+ return fetchJson(
326
+ `${baseUrl}/oauth/token`,
327
+ {
328
+ method: 'POST',
329
+ form,
330
+ },
331
+ { serviceName: 'NeoMail OAuth token' },
332
+ );
333
+ }
334
+
335
+ function buildApiPath(path, query = {}) {
336
+ return appendQuery(path, query);
337
+ }
338
+
339
+ async function fetchAccounts(baseUrl, accessToken) {
340
+ const response = await fetchJson(
341
+ `${baseUrl}/api/v1/mail/accounts`,
342
+ {
343
+ method: 'GET',
344
+ headers: {
345
+ Authorization: `Bearer ${accessToken}`,
346
+ Accept: 'application/json',
347
+ },
348
+ },
349
+ { serviceName: 'NeoMail accounts' },
350
+ );
351
+ return Array.isArray(response?.accounts) ? response.accounts : [];
352
+ }
353
+
354
+ async function fetchUserInfo(baseUrl, accessToken) {
355
+ return fetchJson(
356
+ `${baseUrl}/oauth/userinfo`,
357
+ {
358
+ method: 'GET',
359
+ headers: {
360
+ Authorization: `Bearer ${accessToken}`,
361
+ Accept: 'application/json',
362
+ },
363
+ },
364
+ { serviceName: 'NeoMail userinfo' },
365
+ );
366
+ }
367
+
368
+ function connectionLabelForUser(info = {}, baseUrl, accounts = []) {
369
+ const email = trimText(info.email);
370
+ if (email) return email;
371
+ const username = trimText(info.preferred_username || info.username);
372
+ const host = new URL(baseUrl).host;
373
+ if (username) return `${username}@${host}`;
374
+ if (Array.isArray(accounts) && accounts.length === 1) {
375
+ const emailAddress = trimText(accounts[0]?.emailAddress);
376
+ if (emailAddress) return emailAddress;
377
+ }
378
+ return `neomail:${host}`;
379
+ }
380
+
381
+ function decodeCredentials(connection) {
382
+ try {
383
+ return JSON.parse(decryptValue(connection.credentials_json || '{}') || '{}');
384
+ } catch {
385
+ return {};
386
+ }
387
+ }
388
+
389
+ function summarizeAccountRow(row, envStatus) {
390
+ if (!envStatus.configured) {
391
+ return {
392
+ id: row?.id || null,
393
+ status: 'env_not_configured',
394
+ connected: false,
395
+ accountEmail: row?.account_email || null,
396
+ lastConnectedAt: row?.last_connected_at || null,
397
+ accessMode: 'read_write',
398
+ };
399
+ }
400
+ if (!row) {
401
+ return {
402
+ id: null,
403
+ status: 'not_connected',
404
+ connected: false,
405
+ accountEmail: null,
406
+ lastConnectedAt: null,
407
+ accessMode: 'read_write',
408
+ };
409
+ }
410
+ return {
411
+ id: row.id || null,
412
+ status: row.status || 'not_connected',
413
+ connected: row.status === 'connected',
414
+ accountEmail: row.account_email || null,
415
+ lastConnectedAt: row.last_connected_at || null,
416
+ accessMode: getConnectionAccessMode(row),
417
+ };
418
+ }
419
+
420
+ function buildSnapshot(provider, connectionRows, context = {}) {
421
+ const env = provider.getEnvStatus(context);
422
+ const accounts = (Array.isArray(connectionRows) ? connectionRows : [])
423
+ .slice()
424
+ .sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')))
425
+ .map((row) => summarizeAccountRow(row, env));
426
+ const connectedAccounts = accounts.filter((account) => account.connected);
427
+ return {
428
+ id: provider.key,
429
+ label: provider.label,
430
+ description: provider.description,
431
+ icon: provider.icon,
432
+ apps: [
433
+ {
434
+ id: NEOMAIL_APP.id,
435
+ label: NEOMAIL_APP.label,
436
+ description: NEOMAIL_APP.description,
437
+ accounts,
438
+ connection: {
439
+ status: !env.configured ? 'env_not_configured' : connectedAccounts.length > 0 ? 'connected' : 'not_connected',
440
+ connected: connectedAccounts.length > 0,
441
+ accountCount: connectedAccounts.length,
442
+ accountEmail: connectedAccounts.length === 1 ? connectedAccounts[0].accountEmail : null,
443
+ lastConnectedAt: connectedAccounts.map((account) => account.lastConnectedAt).filter(Boolean).sort().reverse()[0] || null,
444
+ },
445
+ availableToolCount:
446
+ env.configured && connectedAccounts.length > 0 ? TOOL_DEFINITIONS.length : 0,
447
+ },
448
+ ],
449
+ env,
450
+ connection: {
451
+ status: !env.configured ? 'env_not_configured' : connectedAccounts.length > 0 ? 'connected' : 'not_connected',
452
+ connected: connectedAccounts.length > 0,
453
+ accountEmail: connectedAccounts.length === 1 ? connectedAccounts[0].accountEmail : null,
454
+ accountCount: connectedAccounts.length,
455
+ appCount: connectedAccounts.length > 0 ? 1 : 0,
456
+ lastConnectedAt: connectedAccounts.map((account) => account.lastConnectedAt).filter(Boolean).sort().reverse()[0] || null,
457
+ },
458
+ availableToolCount:
459
+ env.configured && connectedAccounts.length > 0 ? TOOL_DEFINITIONS.length : 0,
460
+ connectPrompt: provider.connectPrompt,
461
+ supportsMultipleAccounts: provider.supportsMultipleAccounts,
462
+ connectionMethod: provider.connectionMethod,
463
+ };
464
+ }
465
+
466
+ async function ensureValidAccessToken(connection) {
467
+ const credentials = decodeCredentials(connection);
468
+ const baseUrl = normalizeNeoMailBaseUrl(credentials.baseUrl);
469
+ const accessToken = trimText(credentials.access_token);
470
+ const refreshToken = trimText(credentials.refresh_token);
471
+ const clientId = trimText(credentials.client_id);
472
+ const expiresAtMs = Number(credentials.expires_at_ms || 0);
473
+ const needsRefresh = !accessToken || !expiresAtMs || Date.now() >= (expiresAtMs - 60 * 1000);
474
+
475
+ if (!needsRefresh) {
476
+ return { baseUrl, accessToken, credentials };
477
+ }
478
+ if (!refreshToken || !clientId) {
479
+ throw new Error('NeoMail refresh token is missing. Reconnect the NeoMail account.');
480
+ }
481
+
482
+ const refreshed = await exchangeNeoMailToken(baseUrl, {
483
+ grant_type: 'refresh_token',
484
+ client_id: clientId,
485
+ refresh_token: refreshToken,
486
+ });
487
+ const nextCredentials = {
488
+ ...credentials,
489
+ access_token: trimText(refreshed.access_token),
490
+ refresh_token: trimText(refreshed.refresh_token) || refreshToken,
491
+ scope: trimText(refreshed.scope) || credentials.scope,
492
+ token_type: trimText(refreshed.token_type) || 'Bearer',
493
+ expires_at_ms: Date.now() + (Math.max(1, Number(refreshed.expires_in) || 3600) * 1000),
494
+ };
495
+ return {
496
+ baseUrl,
497
+ accessToken: nextCredentials.access_token,
498
+ credentials: nextCredentials,
499
+ };
500
+ }
501
+
502
+ async function requestNeoMail(connection, path, options = {}) {
503
+ const { baseUrl, accessToken, credentials } = await ensureValidAccessToken(connection);
504
+ const response = await fetchJson(
505
+ `${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`,
506
+ {
507
+ method: options.method || 'GET',
508
+ headers: {
509
+ Authorization: `Bearer ${accessToken}`,
510
+ Accept: 'application/json',
511
+ },
512
+ ...(options.json === undefined ? {} : { json: options.json }),
513
+ },
514
+ { serviceName: 'NeoMail API' },
515
+ );
516
+ return { response, credentials, baseUrl, accessToken };
517
+ }
518
+
519
+ async function fetchNeoMailTriggerEvents(connection, config = {}, options = {}) {
520
+ const path = buildApiPath('/api/v1/mail/events', {
521
+ accountId: trimText(config.mailAccountId || config.mail_account_id) || undefined,
522
+ folder: trimText(config.folder) || undefined,
523
+ q: trimText(config.query) || undefined,
524
+ unread: config.unreadOnly === true ? 'true' : undefined,
525
+ since: trimText(options.since) || undefined,
526
+ limit:
527
+ Number(options.limit) > 0
528
+ ? String(Math.min(Number(options.limit), 200))
529
+ : '100',
530
+ });
531
+ const { response, credentials } = await requestNeoMail(connection, path);
532
+ const events = Array.isArray(response?.events) ? response.events : [];
533
+ const rows = events.map((event) => {
534
+ const messageId = trimText(event?.id);
535
+ return {
536
+ fingerprint: `neomail:${connection.id}:${messageId}`,
537
+ timestamp: trimText(event?.createdAt) || new Date().toISOString(),
538
+ context: {
539
+ triggerEvent: {
540
+ provider: 'neomail',
541
+ connectionId: connection.id,
542
+ messageId,
543
+ threadId: trimText(event?.threadId) || null,
544
+ accountId: trimText(event?.accountId) || null,
545
+ accountEmail: trimText(event?.accountEmail) || null,
546
+ accountLabel: trimText(event?.accountLabel) || null,
547
+ remoteMessageId: trimText(event?.remoteMessageId) || null,
548
+ from: event?.from && typeof event.from === 'object'
549
+ ? {
550
+ name: trimText(event.from.name),
551
+ address: trimText(event.from.address),
552
+ }
553
+ : { name: '', address: '' },
554
+ subject: trimText(event?.subject),
555
+ snippet: trimText(event?.snippet),
556
+ folderPath: trimText(event?.folderPath) || 'INBOX',
557
+ receivedAt: trimText(event?.receivedAt) || null,
558
+ sentAt: trimText(event?.sentAt) || null,
559
+ createdAt: trimText(event?.createdAt) || null,
560
+ updatedAt: trimText(event?.updatedAt) || null,
561
+ isRead: event?.isRead === true,
562
+ },
563
+ },
564
+ };
565
+ });
566
+ return { rows, credentials };
567
+ }
568
+
569
+ function normalizeAddressList(values) {
570
+ const list = Array.isArray(values) ? values : [];
571
+ return list
572
+ .map((entry) => {
573
+ if (typeof entry === 'string') {
574
+ const address = trimText(entry);
575
+ return address ? { address } : null;
576
+ }
577
+ if (entry && typeof entry === 'object') {
578
+ const address = trimText(entry.address);
579
+ if (!address) return null;
580
+ const name = trimText(entry.name);
581
+ return name ? { name, address } : { address };
582
+ }
583
+ return null;
584
+ })
585
+ .filter(Boolean);
586
+ }
587
+
588
+ async function resolveMailAccountSelector(connection, args, options = {}) {
589
+ const directId = trimText(args.mail_account_id || args.account_id);
590
+ if (directId) {
591
+ return { id: directId, accounts: null, credentials: null };
592
+ }
593
+
594
+ const selectorEmail = trimText(args.mail_account_email).toLowerCase();
595
+ const { response, credentials } = await requestNeoMail(connection, '/api/v1/mail/accounts');
596
+ const accounts = Array.isArray(response?.accounts) ? response.accounts : [];
597
+ if (selectorEmail) {
598
+ const match = accounts.find(
599
+ (account) => trimText(account?.emailAddress).toLowerCase() === selectorEmail,
600
+ );
601
+ if (!match) {
602
+ throw new Error(`No NeoMail mailbox matches ${selectorEmail}.`);
603
+ }
604
+ return { id: String(match.id), accounts, credentials };
605
+ }
606
+ if (options.required === true) {
607
+ if (accounts.length === 0) {
608
+ throw new Error('The connected NeoMail account has no mailboxes yet.');
609
+ }
610
+ if (accounts.length > 1) {
611
+ const labels = accounts
612
+ .map((account) => trimText(account?.emailAddress) || String(account?.id || 'unknown'))
613
+ .join(', ');
614
+ throw new Error(
615
+ `Multiple NeoMail mailboxes are available (${labels}). Re-run the tool with mail_account_id or mail_account_email.`,
616
+ );
617
+ }
618
+ return { id: String(accounts[0].id), accounts, credentials };
619
+ }
620
+ return { id: null, accounts, credentials };
621
+ }
622
+
623
+ async function executeNeoMailTool(toolName, args, connection) {
624
+ if (toolName === 'neomail_list_accounts') {
625
+ const { response, credentials } = await requestNeoMail(connection, '/api/v1/mail/accounts');
626
+ return { result: response, credentials };
627
+ }
628
+
629
+ if (toolName === 'neomail_list_threads') {
630
+ const resolved = await resolveMailAccountSelector(connection, args, { required: false });
631
+ const { response, credentials } = await requestNeoMail(
632
+ connection,
633
+ buildApiPath('/api/v1/mail/threads', {
634
+ accountId: resolved.id || undefined,
635
+ folder: trimText(args.folder) || undefined,
636
+ q: trimText(args.query) || undefined,
637
+ unread: args.unread_only === true ? 'true' : undefined,
638
+ limit: Number(args.limit) > 0 ? String(Math.min(Number(args.limit), 100)) : undefined,
639
+ }),
640
+ );
641
+ return { result: response, credentials: resolved.credentials || credentials };
642
+ }
643
+
644
+ if (toolName === 'neomail_get_thread') {
645
+ const threadId = trimText(args.thread_id);
646
+ if (!threadId) throw new Error('thread_id is required.');
647
+ const { response, credentials } = await requestNeoMail(connection, `/api/v1/mail/threads/${encodeURIComponent(threadId)}`);
648
+ return { result: response, credentials };
649
+ }
650
+
651
+ if (toolName === 'neomail_search_messages') {
652
+ const query = trimText(args.query);
653
+ if (!query) throw new Error('query is required.');
654
+ const resolved = await resolveMailAccountSelector(connection, args, { required: false });
655
+ const { response, credentials } = await requestNeoMail(
656
+ connection,
657
+ buildApiPath('/api/v1/mail/search', {
658
+ accountId: resolved.id || undefined,
659
+ q: query,
660
+ limit: Number(args.limit) > 0 ? String(Math.min(Number(args.limit), 100)) : undefined,
661
+ }),
662
+ );
663
+ return { result: response, credentials: resolved.credentials || credentials };
664
+ }
665
+
666
+ if (toolName === 'neomail_save_draft') {
667
+ const resolved = await resolveMailAccountSelector(connection, args, { required: true });
668
+ const payload = {
669
+ ...(trimText(args.thread_id) ? { threadId: trimText(args.thread_id) } : {}),
670
+ accountId: resolved.id,
671
+ to: normalizeAddressList(args.to),
672
+ cc: normalizeAddressList(args.cc),
673
+ bcc: normalizeAddressList(args.bcc),
674
+ subject: trimText(args.subject),
675
+ bodyText: String(args.body_text || ''),
676
+ ...(trimText(args.body_html) ? { bodyHtml: String(args.body_html) } : {}),
677
+ ...(trimText(args.scheduled_for) ? { scheduledFor: trimText(args.scheduled_for) } : {}),
678
+ };
679
+ if (payload.to.length === 0) {
680
+ throw new Error('At least one recipient is required in to.');
681
+ }
682
+ const draftId = trimText(args.draft_id);
683
+ const { response, credentials } = await requestNeoMail(
684
+ connection,
685
+ draftId
686
+ ? `/api/v1/mail/drafts/${encodeURIComponent(draftId)}`
687
+ : '/api/v1/mail/drafts',
688
+ {
689
+ method: draftId ? 'PUT' : 'POST',
690
+ json: payload,
691
+ },
692
+ );
693
+ return { result: response, credentials: resolved.credentials || credentials };
694
+ }
695
+
696
+ if (toolName === 'neomail_send_draft') {
697
+ const draftId = trimText(args.draft_id);
698
+ if (!draftId) throw new Error('draft_id is required.');
699
+ const { response, credentials } = await requestNeoMail(
700
+ connection,
701
+ `/api/v1/mail/send/draft/${encodeURIComponent(draftId)}`,
702
+ {
703
+ method: 'POST',
704
+ json: { immediate: args.immediate !== false },
705
+ },
706
+ );
707
+ return { result: response, credentials };
708
+ }
709
+
710
+ if (toolName === 'neomail_update_thread') {
711
+ const threadId = trimText(args.thread_id);
712
+ if (!threadId) throw new Error('thread_id is required.');
713
+ const payload = {};
714
+ if (args.is_read !== undefined) payload.isRead = args.is_read === true;
715
+ if (args.starred !== undefined) payload.starred = args.starred === true;
716
+ if (args.archived !== undefined) payload.archived = args.archived === true;
717
+ if (args.trashed !== undefined) payload.trashed = args.trashed === true;
718
+ if (Array.isArray(args.labels)) payload.labels = args.labels.map((label) => String(label || '')).filter(Boolean);
719
+ const { response, credentials } = await requestNeoMail(
720
+ connection,
721
+ `/api/v1/mail/threads/${encodeURIComponent(threadId)}`,
722
+ {
723
+ method: 'PATCH',
724
+ json: payload,
725
+ },
726
+ );
727
+ return { result: response, credentials };
728
+ }
729
+
730
+ if (toolName === 'neomail_ai_summarize_thread') {
731
+ const threadId = trimText(args.thread_id);
732
+ if (!threadId) throw new Error('thread_id is required.');
733
+ const { response, credentials } = await requestNeoMail(
734
+ connection,
735
+ `/api/v1/mail/ai/thread/${encodeURIComponent(threadId)}/summary`,
736
+ { method: 'POST', json: {} },
737
+ );
738
+ return { result: response, credentials };
739
+ }
740
+
741
+ if (toolName === 'neomail_ai_improve_draft') {
742
+ const draftId = trimText(args.draft_id);
743
+ if (!draftId) throw new Error('draft_id is required.');
744
+ const { response, credentials } = await requestNeoMail(
745
+ connection,
746
+ `/api/v1/mail/ai/draft/${encodeURIComponent(draftId)}/improve`,
747
+ {
748
+ method: 'POST',
749
+ json: trimText(args.instruction)
750
+ ? { instruction: trimText(args.instruction) }
751
+ : {},
752
+ },
753
+ );
754
+ return { result: response, credentials };
755
+ }
756
+
757
+ if (toolName === 'neomail_ai_ask_inbox') {
758
+ const query = trimText(args.query);
759
+ if (!query) throw new Error('query is required.');
760
+ const { response, credentials } = await requestNeoMail(
761
+ connection,
762
+ '/api/v1/mail/ai/ask',
763
+ {
764
+ method: 'POST',
765
+ json: { query },
766
+ },
767
+ );
768
+ return { result: response, credentials };
769
+ }
770
+
771
+ throw new Error(`Unsupported NeoMail tool: ${toolName}`);
772
+ }
773
+
774
+ function createNeoMailProvider() {
775
+ return {
776
+ key: NEOMAIL_PROVIDER_KEY,
777
+ label: 'NeoMail',
778
+ description:
779
+ 'Connect a self-hosted NeoMail server so NeoAgent can search inboxes, draft replies, send mail, and trigger automations from new email.',
780
+ icon: 'neomail',
781
+ apps: [NEOMAIL_APP],
782
+ connectPrompt:
783
+ 'Add the NeoMail backend URL once, then connect with OAuth. NeoAgent can work across every mailbox visible to that NeoMail user.',
784
+ supportsMultipleAccounts: true,
785
+ connectionMethod: 'oauth',
786
+ getApp(appId) {
787
+ return String(appId || '').trim() === NEOMAIL_APP.id ? NEOMAIL_APP : null;
788
+ },
789
+ getToolAppId(toolName) {
790
+ return toolAppMap.get(String(toolName || '').trim()) || null;
791
+ },
792
+ getEnvStatus(context = {}) {
793
+ const normalizedUserId = Number(context.userId);
794
+ const config = Number.isInteger(normalizedUserId) && normalizedUserId > 0
795
+ ? parseConfigInput(getProviderConfig(normalizedUserId, NEOMAIL_PROVIDER_KEY, context.agentId))
796
+ : { baseUrl: '' };
797
+ const configured = Boolean(trimText(config.baseUrl));
798
+ return {
799
+ configured,
800
+ missing: configured ? [] : ['baseUrl'],
801
+ summary: configured
802
+ ? 'NeoMail is ready for account connections.'
803
+ : 'Add the NeoMail backend URL to enable account connections.',
804
+ setupMode: 'user',
805
+ };
806
+ },
807
+ getToolDefinitions(options = {}) {
808
+ const connectedAppIds = new Set(options.connectedAppIds || []);
809
+ return connectedAppIds.has(NEOMAIL_APP.id) ? TOOL_DEFINITIONS.slice() : [];
810
+ },
811
+ supportsTool(toolName) {
812
+ return toolAppMap.has(String(toolName || '').trim());
813
+ },
814
+ buildSnapshot(connectionRows, context = {}) {
815
+ return buildSnapshot(this, connectionRows, context);
816
+ },
817
+ summarizeForModel(snapshot) {
818
+ if (!snapshot?.env?.configured) {
819
+ return 'NeoMail: setup is not complete for this user yet. Tell them to add the NeoMail backend URL in Official Integrations first.';
820
+ }
821
+ if (!snapshot.connection?.connected) {
822
+ return 'NeoMail: setup is ready, but no NeoMail account is connected yet. Tell the user to connect NeoMail in Official Integrations first.';
823
+ }
824
+ return 'NeoMail: native inbox access is connected in this run with tools for account listing, inbox search, thread reads, drafts, sending, thread updates, and inbox AI.';
825
+ },
826
+ async beginOAuth({ state, codeVerifier, userId, agentId, appKey }) {
827
+ if (String(appKey || '').trim() !== NEOMAIL_APP.id) {
828
+ throw new Error(`Unknown NeoMail app: ${appKey || 'missing app key'}`);
829
+ }
830
+ const config = parseConfigInput(
831
+ getProviderConfig(Number(userId), NEOMAIL_PROVIDER_KEY, resolveAgentId(Number(userId), agentId || null)),
832
+ );
833
+ const baseUrl = normalizeNeoMailBaseUrl(config.baseUrl);
834
+ const bootstrap = await bootstrapNeoMailCompanion(baseUrl);
835
+ const clientId = trimText(bootstrap.clientId);
836
+ if (!clientId) {
837
+ throw new Error('NeoMail companion bootstrap did not return a client ID.');
838
+ }
839
+ const scope = Array.isArray(bootstrap.scopes) && bootstrap.scopes.length > 0
840
+ ? bootstrap.scopes.join(' ')
841
+ : NEOMAIL_COMPANION_SCOPES.join(' ');
842
+ const authorizeUrl = appendQuery(
843
+ trimText(bootstrap.authorizationEndpoint) || `${baseUrl}/oauth/authorize`,
844
+ {
845
+ response_type: 'code',
846
+ client_id: clientId,
847
+ redirect_uri: trimText(bootstrap.redirectUri) || getCallbackUrl(),
848
+ state,
849
+ code_challenge: crypto
850
+ .createHash('sha256')
851
+ .update(String(codeVerifier || ''))
852
+ .digest('base64')
853
+ .replace(/\+/g, '-')
854
+ .replace(/\//g, '_')
855
+ .replace(/=+$/g, ''),
856
+ code_challenge_method: 'S256',
857
+ scope,
858
+ },
859
+ );
860
+ return { url: authorizeUrl };
861
+ },
862
+ async finishOAuth({ userId, agentId, code, codeVerifier }) {
863
+ const config = parseConfigInput(
864
+ getProviderConfig(Number(userId), NEOMAIL_PROVIDER_KEY, resolveAgentId(Number(userId), agentId || null)),
865
+ );
866
+ const baseUrl = normalizeNeoMailBaseUrl(config.baseUrl);
867
+ const bootstrap = await bootstrapNeoMailCompanion(baseUrl);
868
+ const tokenSet = await exchangeNeoMailToken(baseUrl, {
869
+ grant_type: 'authorization_code',
870
+ client_id: trimText(bootstrap.clientId),
871
+ code: trimText(code),
872
+ redirect_uri: trimText(bootstrap.redirectUri) || getCallbackUrl(),
873
+ code_verifier: trimText(codeVerifier),
874
+ });
875
+ const accessToken = trimText(tokenSet.access_token);
876
+ const refreshToken = trimText(tokenSet.refresh_token);
877
+ if (!accessToken || !refreshToken) {
878
+ throw new Error('NeoMail did not return durable OAuth credentials.');
879
+ }
880
+
881
+ const [userInfo, mailAccounts] = await Promise.all([
882
+ fetchUserInfo(baseUrl, accessToken),
883
+ fetchAccounts(baseUrl, accessToken).catch(() => []),
884
+ ]);
885
+
886
+ const metadata = {
887
+ baseUrl,
888
+ username: trimText(userInfo?.preferred_username),
889
+ email: trimText(userInfo?.email) || null,
890
+ mailboxCount: Array.isArray(mailAccounts) ? mailAccounts.length : 0,
891
+ mailboxes: Array.isArray(mailAccounts)
892
+ ? mailAccounts.slice(0, 20).map((account) => ({
893
+ id: account.id,
894
+ emailAddress: account.emailAddress || null,
895
+ label: account.label || null,
896
+ }))
897
+ : [],
898
+ };
899
+
900
+ return {
901
+ accountEmail: connectionLabelForUser(userInfo, baseUrl, mailAccounts),
902
+ scopes: trimText(tokenSet.scope).split(/\s+/g).filter(Boolean),
903
+ credentials: {
904
+ baseUrl,
905
+ client_id: trimText(bootstrap.clientId),
906
+ access_token: accessToken,
907
+ refresh_token: refreshToken,
908
+ scope: trimText(tokenSet.scope),
909
+ token_type: trimText(tokenSet.token_type) || 'Bearer',
910
+ expires_at_ms: Date.now() + (Math.max(1, Number(tokenSet.expires_in) || 3600) * 1000),
911
+ },
912
+ metadata,
913
+ };
914
+ },
915
+ async executeTool(toolName, args, connection) {
916
+ return executeNeoMailTool(toolName, args || {}, connection);
917
+ },
918
+ async fetchTriggerEvents({ connection, config, since, limit }) {
919
+ return fetchNeoMailTriggerEvents(connection, config, { since, limit });
920
+ },
921
+ getUserConfig({ userId, agentId }) {
922
+ const normalizedUserId = Number(userId);
923
+ const scopedAgentId = resolveAgentId(normalizedUserId, agentId || null);
924
+ const storedConfig = parseConfigInput(
925
+ getProviderConfig(normalizedUserId, NEOMAIL_PROVIDER_KEY, scopedAgentId),
926
+ );
927
+ const accountCount = db.prepare(
928
+ `SELECT COUNT(*) AS count
929
+ FROM integration_connections
930
+ WHERE user_id = ? AND agent_id = ? AND provider_key = ? AND status = 'connected'`,
931
+ ).get(normalizedUserId, scopedAgentId, NEOMAIL_PROVIDER_KEY)?.count || 0;
932
+ return {
933
+ baseUrl: storedConfig.baseUrl,
934
+ configured: Boolean(storedConfig.baseUrl),
935
+ accountCount,
936
+ hasConnectedAccount: accountCount > 0,
937
+ };
938
+ },
939
+ async saveUserConfig({ userId, agentId, config }) {
940
+ const normalizedUserId = Number(userId);
941
+ if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
942
+ throw new Error('A valid user is required to save NeoMail configuration.');
943
+ }
944
+ const scopedAgentId = resolveAgentId(normalizedUserId, agentId || null);
945
+ const existingConfig = parseConfigInput(
946
+ getProviderConfig(normalizedUserId, NEOMAIL_PROVIDER_KEY, scopedAgentId),
947
+ );
948
+ const parsedConfig = parseConfigInput(config, existingConfig);
949
+ const baseUrl = normalizeNeoMailBaseUrl(parsedConfig.baseUrl);
950
+ const authStatus = await fetchNeoMailAuthStatus(baseUrl);
951
+ await bootstrapNeoMailCompanion(baseUrl);
952
+
953
+ setProviderConfig(normalizedUserId, NEOMAIL_PROVIDER_KEY, { baseUrl }, scopedAgentId);
954
+
955
+ if (existingConfig.baseUrl && existingConfig.baseUrl !== baseUrl) {
956
+ db.prepare(
957
+ 'DELETE FROM integration_connections WHERE user_id = ? AND agent_id = ? AND provider_key = ?',
958
+ ).run(normalizedUserId, scopedAgentId, NEOMAIL_PROVIDER_KEY);
959
+ }
960
+
961
+ const accountCount = db.prepare(
962
+ `SELECT COUNT(*) AS count
963
+ FROM integration_connections
964
+ WHERE user_id = ? AND agent_id = ? AND provider_key = ? AND status = 'connected'`,
965
+ ).get(normalizedUserId, scopedAgentId, NEOMAIL_PROVIDER_KEY)?.count || 0;
966
+
967
+ return {
968
+ baseUrl,
969
+ configured: true,
970
+ hasConnectedAccount: accountCount > 0,
971
+ accountCount,
972
+ authenticatedOnServer: authStatus?.authenticated === true,
973
+ hasUser: authStatus?.hasUser === true,
974
+ };
975
+ },
976
+ clearUserConfig({ userId, agentId }) {
977
+ const normalizedUserId = Number(userId);
978
+ if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
979
+ throw new Error('A valid user is required to clear NeoMail configuration.');
980
+ }
981
+ const scopedAgentId = resolveAgentId(normalizedUserId, agentId || null);
982
+ deleteProviderConfig(normalizedUserId, NEOMAIL_PROVIDER_KEY, scopedAgentId);
983
+ db.prepare(
984
+ 'DELETE FROM integration_connections WHERE user_id = ? AND agent_id = ? AND provider_key = ?',
985
+ ).run(normalizedUserId, scopedAgentId, NEOMAIL_PROVIDER_KEY);
986
+ return { cleared: true };
987
+ },
988
+ };
989
+ }
990
+
991
+ module.exports = {
992
+ createNeoMailProvider,
993
+ };