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.
- package/README.md +193 -0
- package/bin/otherwise.js +5 -0
- package/frontend/404.html +84 -0
- package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
- package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
- package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
- package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
- package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
- package/frontend/assets/index-BLux5ps4.js +21 -0
- package/frontend/assets/index-Blh8_TEM.js +5272 -0
- package/frontend/assets/index-BpQ1PuKu.js +18 -0
- package/frontend/assets/index-Df737c8w.css +1 -0
- package/frontend/assets/index-xaYHL6wb.js +113 -0
- package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
- package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/frontend/assets/transformers-tULNc5V3.js +31 -0
- package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
- package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
- package/frontend/assets/worker-2d5ABSLU.js +31 -0
- package/frontend/banner.png +0 -0
- package/frontend/favicon.svg +3 -0
- package/frontend/google55e5ec47ee14a5f8.html +1 -0
- package/frontend/index.html +234 -0
- package/frontend/manifest.json +17 -0
- package/frontend/pdf.worker.min.mjs +21 -0
- package/frontend/robots.txt +5 -0
- package/frontend/sitemap.xml +27 -0
- package/package.json +81 -0
- package/src/agent/index.js +1066 -0
- package/src/agent/location.js +51 -0
- package/src/agent/prompt.js +548 -0
- package/src/agent/tools.js +4372 -0
- package/src/browser/detect.js +68 -0
- package/src/browser/session.js +1109 -0
- package/src/config.js +137 -0
- package/src/email/client.js +503 -0
- package/src/index.js +557 -0
- package/src/inference/anthropic.js +113 -0
- package/src/inference/google.js +373 -0
- package/src/inference/index.js +81 -0
- package/src/inference/ollama.js +383 -0
- package/src/inference/openai.js +140 -0
- package/src/inference/openrouter.js +378 -0
- package/src/inference/xai.js +200 -0
- package/src/logBridge.js +9 -0
- package/src/models.js +146 -0
- package/src/remote/client.js +225 -0
- package/src/scheduler/cron.js +243 -0
- package/src/server.js +3876 -0
- package/src/storage/db.js +1135 -0
- package/src/storage/supabase.js +364 -0
- package/src/tunnel/cloudflare.js +241 -0
- package/src/ui/components/App.jsx +687 -0
- package/src/ui/components/BrowserSelect.jsx +111 -0
- package/src/ui/components/FilePicker.jsx +472 -0
- package/src/ui/components/Header.jsx +444 -0
- package/src/ui/components/HelpPanel.jsx +173 -0
- package/src/ui/components/HistoryPanel.jsx +158 -0
- package/src/ui/components/MessageList.jsx +235 -0
- package/src/ui/components/ModelSelector.jsx +304 -0
- package/src/ui/components/PromptInput.jsx +515 -0
- package/src/ui/components/StreamingResponse.jsx +134 -0
- package/src/ui/components/ThinkingIndicator.jsx +365 -0
- package/src/ui/components/ToolExecution.jsx +714 -0
- package/src/ui/components/index.js +82 -0
- package/src/ui/context/TerminalContext.jsx +150 -0
- package/src/ui/context/index.js +13 -0
- package/src/ui/hooks/index.js +16 -0
- package/src/ui/hooks/useChatState.js +675 -0
- package/src/ui/hooks/useCommands.js +280 -0
- package/src/ui/hooks/useFileAttachments.js +216 -0
- package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
- package/src/ui/hooks/useNotifications.js +185 -0
- package/src/ui/hooks/useTerminalSize.js +151 -0
- package/src/ui/hooks/useWebSocket.js +273 -0
- package/src/ui/index.js +94 -0
- package/src/ui/ink-runner.js +22 -0
- package/src/ui/utils/formatters.js +424 -0
- package/src/ui/utils/index.js +6 -0
- 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
|
+
};
|