otherwise-cli 0.1.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.
Files changed (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. package/src/ui/utils/markdown.js +166 -0
package/src/config.js ADDED
@@ -0,0 +1,137 @@
1
+ import Conf from 'conf';
2
+
3
+ // Default configuration
4
+ const defaults = {
5
+ apiKeys: {
6
+ anthropic: null,
7
+ openai: null,
8
+ google: null,
9
+ xai: null,
10
+ openrouter: null,
11
+ },
12
+ model: 'claude-sonnet-4-20250514',
13
+ maxTokens: 8192,
14
+ temperature: 0.7,
15
+ ollamaUrl: 'http://localhost:11434',
16
+ // Email configuration
17
+ // MyMX for receiving (inbound) + Resend for sending (outbound)
18
+ mymx: {
19
+ secret: null, // Webhook signing secret from MyMX dashboard
20
+ },
21
+ resend: {
22
+ apiKey: null, // API key from resend.com
23
+ from: null, // Default "from" address (e.g., "you@yourdomain.com")
24
+ },
25
+ // Legacy email config (kept for backward compatibility)
26
+ email: {
27
+ smtp: { host: null, port: 587, user: null, pass: null },
28
+ imap: { host: null, port: 993, user: null, pass: null },
29
+ },
30
+ permissions: {
31
+ fileRead: ['~/*'],
32
+ fileWrite: ['~/ai-workspace/*'],
33
+ shell: true,
34
+ email: true,
35
+ },
36
+ server: {
37
+ port: 3000,
38
+ },
39
+ tunnel: {
40
+ enabled: false,
41
+ domain: null,
42
+ },
43
+ remote: {
44
+ backendUrl: null,
45
+ pairingToken: null,
46
+ },
47
+ // Browser automation settings
48
+ browserHeadless: false, // Run browser visible by default (user can see navigation)
49
+ // Use system/default browser: 'chrome' | 'msedge' | 'chromium' | executable path, or null to prompt user
50
+ browserChannel: null,
51
+ };
52
+
53
+ // Create config store
54
+ export const config = new Conf({
55
+ projectName: 'otherwise',
56
+ defaults,
57
+ schema: {
58
+ apiKeys: {
59
+ type: 'object',
60
+ properties: {
61
+ anthropic: { type: ['string', 'null'] },
62
+ openai: { type: ['string', 'null'] },
63
+ google: { type: ['string', 'null'] },
64
+ xai: { type: ['string', 'null'] },
65
+ openrouter: { type: ['string', 'null'] },
66
+ },
67
+ },
68
+ model: { type: 'string' },
69
+ maxTokens: { type: 'number', minimum: 1, maximum: 200000 },
70
+ temperature: { type: 'number', minimum: 0, maximum: 2 },
71
+ ollamaUrl: { type: 'string' },
72
+ permissions: {
73
+ type: 'object',
74
+ properties: {
75
+ fileRead: { type: 'array', items: { type: 'string' } },
76
+ fileWrite: { type: 'array', items: { type: 'string' } },
77
+ shell: { type: 'boolean' },
78
+ email: { type: 'boolean' },
79
+ },
80
+ },
81
+ browserHeadless: { type: 'boolean' },
82
+ browserChannel: { type: ['string', 'null'] },
83
+ },
84
+ });
85
+
86
+ // Migration: fix OpenRouter key that was stored at root level instead of apiKeys
87
+ // This happened because 'openrouter' was missing from the keyMap in the CLI config set command
88
+ if (config.has('openrouter') && !config.get('apiKeys.openrouter')) {
89
+ const misplacedKey = config.get('openrouter');
90
+ if (typeof misplacedKey === 'string') {
91
+ config.set('apiKeys.openrouter', misplacedKey.trim());
92
+ config.delete('openrouter');
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Get API key for a provider
98
+ * @param {string} provider - Provider name (anthropic, openai, google, xai)
99
+ * @returns {string|null}
100
+ */
101
+ export function getApiKey(provider) {
102
+ return config.get(`apiKeys.${provider}`) || null;
103
+ }
104
+
105
+ /**
106
+ * Get the configured model
107
+ * @returns {string}
108
+ */
109
+ export function getModel() {
110
+ return config.get('model');
111
+ }
112
+
113
+ /**
114
+ * Get config for frontend (with redacted API keys and secrets)
115
+ * @returns {object}
116
+ */
117
+ export function getPublicConfig() {
118
+ const fullConfig = config.store;
119
+ return {
120
+ ...fullConfig,
121
+ // Redact API keys - only show if configured (boolean)
122
+ apiKeys: Object.fromEntries(
123
+ Object.entries(fullConfig.apiKeys || {}).map(([k, v]) => [k, v ? true : false])
124
+ ),
125
+ // Redact MyMX secret - only show if configured (boolean)
126
+ mymx: {
127
+ configured: !!fullConfig.mymx?.secret,
128
+ },
129
+ // Redact Resend config - show configured status and from address (not API key)
130
+ resend: {
131
+ configured: !!fullConfig.resend?.apiKey,
132
+ from: fullConfig.resend?.from || null,
133
+ },
134
+ };
135
+ }
136
+
137
+ export default config;
@@ -0,0 +1,503 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+ import { runAgent } from '../agent/index.js';
3
+ import { getDb } from '../storage/db.js';
4
+
5
+ // ============================================
6
+ // Helper Functions
7
+ // ============================================
8
+
9
+ /**
10
+ * Extract just the email address from a "Name <email>" string
11
+ * @param {string} emailString - Email string like "John Doe <john@example.com>"
12
+ * @returns {string} - Just the email address
13
+ */
14
+ function extractEmailAddress(emailString) {
15
+ if (!emailString) return '';
16
+ const match = emailString.match(/<([^>]+)>/);
17
+ return match ? match[1] : emailString.trim();
18
+ }
19
+
20
+ // ============================================
21
+ // MyMX Webhook Signature Verification
22
+ // ============================================
23
+
24
+ /**
25
+ * Verify MyMX webhook signature
26
+ * @param {string} rawBody - Raw request body as string
27
+ * @param {string} signatureHeader - The MyMX-Signature header value
28
+ * @param {string} secret - Your webhook secret
29
+ * @returns {{ valid: boolean, error?: string }}
30
+ */
31
+ function verifyWebhookSignature(rawBody, signatureHeader, secret) {
32
+ if (!signatureHeader) {
33
+ return { valid: false, error: 'Missing MyMX-Signature header' };
34
+ }
35
+
36
+ if (!secret) {
37
+ // If no secret configured, skip verification (development mode)
38
+ console.warn('[MyMX] No webhook secret configured - skipping signature verification');
39
+ return { valid: true };
40
+ }
41
+
42
+ try {
43
+ // Parse header: t=1734523200,v1=abc123...
44
+ const parts = {};
45
+ for (const part of signatureHeader.split(',')) {
46
+ const [key, value] = part.split('=');
47
+ if (key && value) {
48
+ parts[key] = value;
49
+ }
50
+ }
51
+
52
+ const timestamp = parseInt(parts.t, 10);
53
+ const signature = parts.v1;
54
+
55
+ if (!timestamp || !signature) {
56
+ return { valid: false, error: 'Invalid signature header format' };
57
+ }
58
+
59
+ // Check timestamp (5 minute tolerance to prevent replay attacks)
60
+ const now = Math.floor(Date.now() / 1000);
61
+ if (Math.abs(now - timestamp) > 300) {
62
+ return { valid: false, error: 'Signature timestamp expired (>5 minutes old)' };
63
+ }
64
+
65
+ // Compute expected signature: HMAC-SHA256 of "{timestamp}.{rawBody}"
66
+ const signedPayload = `${timestamp}.${rawBody}`;
67
+ const expectedSignature = createHmac('sha256', secret)
68
+ .update(signedPayload)
69
+ .digest('hex');
70
+
71
+ // Constant-time comparison to prevent timing attacks
72
+ const sigBuffer = Buffer.from(signature, 'hex');
73
+ const expectedBuffer = Buffer.from(expectedSignature, 'hex');
74
+
75
+ if (sigBuffer.length !== expectedBuffer.length) {
76
+ return { valid: false, error: 'Signature length mismatch' };
77
+ }
78
+
79
+ if (!timingSafeEqual(sigBuffer, expectedBuffer)) {
80
+ return { valid: false, error: 'Invalid signature' };
81
+ }
82
+
83
+ return { valid: true };
84
+ } catch (err) {
85
+ return { valid: false, error: `Signature verification failed: ${err.message}` };
86
+ }
87
+ }
88
+
89
+ // ============================================
90
+ // MyMX Webhook Handler
91
+ // ============================================
92
+
93
+ /**
94
+ * Parse MyMX email payload into a simple structure
95
+ * @param {object} payload - MyMX webhook payload
96
+ * @returns {object} - Simplified email object
97
+ */
98
+ function parseMyMXEmail(payload) {
99
+ const email = payload.email || {};
100
+ const headers = email.headers || {};
101
+ const parsed = email.parsed || {};
102
+ const smtp = email.smtp || {};
103
+ const auth = email.auth || {};
104
+
105
+ return {
106
+ // Event info
107
+ eventId: payload.id,
108
+ eventType: payload.event, // 'email.received'
109
+
110
+ // Email identifiers
111
+ emailId: email.id,
112
+ messageId: headers.message_id,
113
+ receivedAt: email.received_at,
114
+
115
+ // Addresses
116
+ from: headers.from,
117
+ to: headers.to,
118
+ replyTo: parsed.reply_to,
119
+ cc: parsed.cc,
120
+
121
+ // Content
122
+ subject: headers.subject,
123
+ bodyText: parsed.body_text,
124
+ bodyHtml: parsed.body_html,
125
+ date: headers.date,
126
+
127
+ // SMTP envelope (the "real" sender/recipient)
128
+ envelope: {
129
+ mailFrom: smtp.mail_from,
130
+ rcptTo: smtp.rcpt_to,
131
+ helo: smtp.helo,
132
+ },
133
+
134
+ // Attachments
135
+ attachments: (parsed.attachments || []).map(att => ({
136
+ filename: att.filename,
137
+ contentType: att.content_type,
138
+ size: att.size_bytes,
139
+ sha256: att.sha256,
140
+ })),
141
+ attachmentsDownloadUrl: parsed.attachments_download_url,
142
+
143
+ // Thread info (for replies)
144
+ inReplyTo: parsed.in_reply_to,
145
+ references: parsed.references,
146
+
147
+ // Authentication results
148
+ auth: {
149
+ spf: auth.spf,
150
+ dkim: auth.dmarc,
151
+ dmarc: auth.dmarc,
152
+ dmarcPolicy: auth.dmarcPolicy,
153
+ },
154
+
155
+ // Spam score
156
+ spamScore: email.analysis?.spamassassin?.score,
157
+
158
+ // Raw email access
159
+ rawDownloadUrl: email.content?.download?.url,
160
+ rawExpiresAt: email.content?.download?.expires_at,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Handle MyMX webhook for incoming emails
166
+ * @param {object} request - Fastify request
167
+ * @param {object} reply - Fastify reply
168
+ * @param {object} config - Configuration
169
+ */
170
+ export async function handleEmailWebhook(request, reply, config) {
171
+ const secret = config.mymx?.secret;
172
+
173
+ // Get raw body for signature verification
174
+ // The server captures this via preParsing hook
175
+ const rawBody = request.rawBody || JSON.stringify(request.body);
176
+
177
+ // Verify webhook signature
178
+ const signatureHeader = request.headers['mymx-signature'];
179
+ const verification = verifyWebhookSignature(rawBody, signatureHeader, secret);
180
+
181
+ if (!verification.valid) {
182
+ console.error('[MyMX] Webhook verification failed:', verification.error);
183
+ return reply.status(401).send({
184
+ error: 'Webhook verification failed',
185
+ details: verification.error,
186
+ });
187
+ }
188
+
189
+ try {
190
+ const payload = typeof request.body === 'string'
191
+ ? JSON.parse(request.body)
192
+ : request.body;
193
+
194
+ // Verify this is an email.received event
195
+ if (payload.event !== 'email.received') {
196
+ console.log('[MyMX] Ignoring non-email event:', payload.event);
197
+ return reply.status(200).send({ success: true, ignored: true });
198
+ }
199
+
200
+ // Parse the email into a simple structure
201
+ const email = parseMyMXEmail(payload);
202
+
203
+ console.log('[MyMX] šŸ“§ Received email:');
204
+ console.log(` From: ${email.from}`);
205
+ console.log(` To: ${email.to}`);
206
+ console.log(` Subject: ${email.subject}`);
207
+ console.log(` Spam Score: ${email.spamScore ?? 'N/A'}`);
208
+ console.log(` Attachments: ${email.attachments.length}`);
209
+
210
+ // Skip high spam emails
211
+ if (email.spamScore && email.spamScore > 5) {
212
+ console.log('[MyMX] Skipping high-spam email (score:', email.spamScore, ')');
213
+ return reply.status(200).send({ success: true, skipped: 'spam' });
214
+ }
215
+
216
+ // Store the email in the database
217
+ const db = getDb();
218
+
219
+ // Create a new chat for this email
220
+ const chatTitle = `šŸ“§ ${email.subject || 'Email'} - from ${extractEmailAddress(email.from)}`;
221
+ const chatResult = db.prepare('INSERT INTO chats (title) VALUES (?)').run(chatTitle);
222
+ const chatId = chatResult.lastInsertRowid;
223
+
224
+ console.log('[MyMX] Created chat', chatId, 'for incoming email');
225
+
226
+ // Format the email as a user message
227
+ const emailContent = `šŸ“§ **Incoming Email**
228
+
229
+ **From:** ${email.from}
230
+ **To:** ${email.to}
231
+ **Subject:** ${email.subject || '(no subject)'}
232
+ **Date:** ${email.date || email.receivedAt}
233
+ ${email.attachments.length > 0 ? `**Attachments:** ${email.attachments.map(a => a.filename || 'unnamed').join(', ')}` : ''}
234
+
235
+ ---
236
+
237
+ ${email.bodyText || email.bodyHtml?.replace(/<[^>]+>/g, ' ').trim() || '(no body content)'}`;
238
+
239
+ // Save the email as a "user" message (it's incoming)
240
+ const emailMetadata = {
241
+ type: 'email',
242
+ emailId: email.emailId,
243
+ messageId: email.messageId,
244
+ from: email.from,
245
+ to: email.to,
246
+ subject: email.subject,
247
+ spamScore: email.spamScore,
248
+ attachments: email.attachments,
249
+ };
250
+
251
+ db.prepare(`
252
+ INSERT INTO messages (chat_id, role, content, metadata) VALUES (?, ?, ?, ?)
253
+ `).run(chatId, 'user', emailContent, JSON.stringify(emailMetadata));
254
+
255
+ // Create a prompt for the AI to handle the email
256
+ const prompt = `You received an incoming email. Please read it and respond appropriately.
257
+
258
+ ${emailContent}
259
+
260
+ ---
261
+
262
+ **Instructions:**
263
+ - Analyze this email and determine the appropriate action
264
+ - If it requires a response, compose and send one using the send_email tool (reply to: ${extractEmailAddress(email.from)})
265
+ - If it's informational, summarize the key points
266
+ - If it's spam or clearly irrelevant, note that
267
+ - Be helpful but concise`;
268
+
269
+ // Run the agent to process the email
270
+ let response = '';
271
+ try {
272
+ for await (const chunk of runAgent(prompt, [], config)) {
273
+ if (chunk.type === 'text') {
274
+ response += chunk.content;
275
+ }
276
+ }
277
+ console.log('[MyMX] AI processed email, response length:', response.length);
278
+
279
+ // Save the AI's response
280
+ if (response.length > 0) {
281
+ db.prepare(`
282
+ INSERT INTO messages (chat_id, role, content, metadata) VALUES (?, ?, ?, ?)
283
+ `).run(chatId, 'assistant', response, JSON.stringify({ model: config.model }));
284
+
285
+ // Update chat timestamp
286
+ db.prepare('UPDATE chats SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(chatId);
287
+ }
288
+ } catch (agentErr) {
289
+ console.error('[MyMX] Agent error:', agentErr);
290
+ // Save error as assistant message so user knows what happened
291
+ db.prepare(`
292
+ INSERT INTO messages (chat_id, role, content, metadata) VALUES (?, ?, ?, ?)
293
+ `).run(chatId, 'assistant', `Error processing email: ${agentErr.message}`, JSON.stringify({ error: true }));
294
+ }
295
+
296
+ // Return success
297
+ return reply.status(200).send({
298
+ success: true,
299
+ processed: true,
300
+ emailId: email.emailId,
301
+ chatId: chatId,
302
+ });
303
+
304
+ } catch (err) {
305
+ console.error('[MyMX] Webhook processing error:', err);
306
+ // Return 500 so MyMX will retry
307
+ return reply.status(500).send({
308
+ error: 'Processing failed',
309
+ details: err.message,
310
+ });
311
+ }
312
+ }
313
+
314
+ // ============================================
315
+ // Email Sending (via Resend API)
316
+ // ============================================
317
+
318
+ /**
319
+ * Send email via Resend API
320
+ * Resend is a modern email API that works great with custom domains.
321
+ * Get your API key at https://resend.com
322
+ *
323
+ * @param {object} config - Configuration with email settings
324
+ * @param {string} to - Recipient email address
325
+ * @param {string} subject - Email subject
326
+ * @param {string} body - Email body (plain text)
327
+ * @param {object} options - Additional options (html, replyTo, from, etc.)
328
+ * @returns {Promise<object>} - Send result
329
+ */
330
+ export async function sendEmail(config, to, subject, body, options = {}) {
331
+ const resendApiKey = config.resend?.apiKey;
332
+ const defaultFrom = config.resend?.from || config.email?.from;
333
+
334
+ if (!resendApiKey) {
335
+ throw new Error(
336
+ 'Email sending not configured.\n\n' +
337
+ 'šŸ“§ To send emails, you need to set up Resend (https://resend.com):\n\n' +
338
+ '1. Sign up at resend.com and get an API key\n' +
339
+ '2. Add your domain and verify DNS records\n' +
340
+ '3. Add the API key in Settings under "Integrations"\n\n' +
341
+ 'Resend works great with MyMX - MyMX handles receiving, Resend handles sending!'
342
+ );
343
+ }
344
+
345
+ // Build the email payload
346
+ const emailPayload = {
347
+ from: options.from || defaultFrom,
348
+ to: Array.isArray(to) ? to : [to],
349
+ subject,
350
+ text: body,
351
+ };
352
+
353
+ // Validate from address
354
+ if (!emailPayload.from) {
355
+ throw new Error(
356
+ 'No "from" address configured.\n\n' +
357
+ 'Set resend.from in your config to your verified email address\n' +
358
+ '(e.g., "you@yourdomain.com" or "Your Name <you@yourdomain.com>")'
359
+ );
360
+ }
361
+
362
+ // Optional HTML version
363
+ if (options.html) {
364
+ emailPayload.html = options.html;
365
+ }
366
+
367
+ // Optional reply-to
368
+ if (options.replyTo) {
369
+ emailPayload.reply_to = options.replyTo;
370
+ }
371
+
372
+ // Optional CC/BCC
373
+ if (options.cc) {
374
+ emailPayload.cc = Array.isArray(options.cc) ? options.cc : [options.cc];
375
+ }
376
+ if (options.bcc) {
377
+ emailPayload.bcc = Array.isArray(options.bcc) ? options.bcc : [options.bcc];
378
+ }
379
+
380
+ // Send via Resend API
381
+ const response = await fetch('https://api.resend.com/emails', {
382
+ method: 'POST',
383
+ headers: {
384
+ 'Authorization': `Bearer ${resendApiKey}`,
385
+ 'Content-Type': 'application/json',
386
+ },
387
+ body: JSON.stringify(emailPayload),
388
+ });
389
+
390
+ if (!response.ok) {
391
+ const errorData = await response.json().catch(() => ({}));
392
+ throw new Error(
393
+ `Resend API error: ${errorData.message || response.statusText}\n` +
394
+ (errorData.name === 'validation_error'
395
+ ? 'Check that your "from" address is verified in Resend.'
396
+ : '')
397
+ );
398
+ }
399
+
400
+ const result = await response.json();
401
+
402
+ console.log('[Email] Sent via Resend:', result.id);
403
+
404
+ return {
405
+ success: true,
406
+ messageId: result.id,
407
+ };
408
+ }
409
+
410
+ // ============================================
411
+ // List Received Emails
412
+ // ============================================
413
+
414
+ /**
415
+ * Get list of recently received emails from the database
416
+ * @param {number} limit - Max number of emails to return
417
+ * @returns {Array} - Array of email objects
418
+ */
419
+ export function getReceivedEmails(limit = 10) {
420
+ const db = getDb();
421
+
422
+ // Find chats that are emails (title starts with šŸ“§)
423
+ const emailChats = db.prepare(`
424
+ SELECT c.id, c.title, c.created_at, c.updated_at,
425
+ m.content, m.metadata
426
+ FROM chats c
427
+ JOIN messages m ON m.chat_id = c.id AND m.role = 'user'
428
+ WHERE c.title LIKE 'šŸ“§%'
429
+ ORDER BY c.created_at DESC
430
+ LIMIT ?
431
+ `).all(limit);
432
+
433
+ return emailChats.map(chat => {
434
+ const metadata = chat.metadata ? JSON.parse(chat.metadata) : {};
435
+ return {
436
+ chatId: chat.id,
437
+ title: chat.title,
438
+ from: metadata.from,
439
+ to: metadata.to,
440
+ subject: metadata.subject,
441
+ receivedAt: chat.created_at,
442
+ spamScore: metadata.spamScore,
443
+ hasAttachments: (metadata.attachments?.length || 0) > 0,
444
+ };
445
+ });
446
+ }
447
+
448
+ /**
449
+ * Check email - lists recent emails received via MyMX webhooks
450
+ *
451
+ * @param {object} config - Configuration
452
+ * @returns {Promise<string>} - List of recent emails or setup instructions
453
+ */
454
+ export async function checkEmail(config) {
455
+ const mymxConfigured = !!config.mymx?.secret;
456
+
457
+ if (!mymxConfigured) {
458
+ throw new Error(
459
+ 'šŸ“§ Email not configured!\n\n' +
460
+ 'To receive emails, set up MyMX:\n' +
461
+ '1. Sign up at mymx.dev\n' +
462
+ '2. Add your domain and configure MX records\n' +
463
+ '3. Set your webhook endpoint to: /api/email/webhook\n' +
464
+ '4. Add your webhook secret in Settings → Email Integration\n\n' +
465
+ 'MyMX will push emails to your agent automatically!'
466
+ );
467
+ }
468
+
469
+ // Get recent emails from database
470
+ const emails = getReceivedEmails(10);
471
+
472
+ if (emails.length === 0) {
473
+ return (
474
+ 'šŸ“­ No emails received yet.\n\n' +
475
+ 'Emails sent to your domain will appear here automatically.\n' +
476
+ 'Check your MyMX dashboard at mymx.dev to verify your setup.'
477
+ );
478
+ }
479
+
480
+ // Format the email list
481
+ let output = `šŸ“¬ **Recent Emails** (${emails.length} found)\n\n`;
482
+
483
+ for (const email of emails) {
484
+ const date = new Date(email.receivedAt).toLocaleString();
485
+ output += `**${email.subject || '(no subject)'}**\n`;
486
+ output += ` From: ${email.from}\n`;
487
+ output += ` Received: ${date}\n`;
488
+ output += ` Chat ID: ${email.chatId}${email.hasAttachments ? ' šŸ“Ž' : ''}\n\n`;
489
+ }
490
+
491
+ output += `\nšŸ’” Each email creates a chat. Use read_memory with the Chat ID to see the full conversation.`;
492
+
493
+ return output;
494
+ }
495
+
496
+ export default {
497
+ checkEmail,
498
+ sendEmail,
499
+ handleEmailWebhook,
500
+ verifyWebhookSignature,
501
+ parseMyMXEmail,
502
+ getReceivedEmails,
503
+ };