zubo 0.1.27 → 0.1.29
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 +4 -2
- package/migrations/025_sent_messages.sql +18 -0
- package/package.json +1 -1
- package/src/agent/loop.ts +90 -30
- package/src/agent/prompts.ts +11 -5
- package/src/channels/email.ts +177 -41
- package/src/channels/router.ts +26 -0
- package/src/channels/webchat.ts +23 -6
- package/src/config/schema.ts +6 -0
- package/src/email/sent-log.ts +37 -0
- package/src/start.ts +10 -8
- package/src/tools/builtin/email-send.ts +112 -0
- package/src/tools/builtin-integrations/google/gmail/handler.ts +104 -34
- package/src/tools/executor.ts +13 -3
- package/src/tools/permissions.ts +7 -3
package/README.md
CHANGED
|
@@ -98,9 +98,10 @@ User Message
|
|
|
98
98
|
All config lives in `~/.zubo/config.json`. Run `zubo setup` for interactive configuration, or set values directly:
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
|
-
zubo config set activeProvider anthropic
|
|
102
|
-
zubo config set smartRouting.enabled true
|
|
101
|
+
zubo config set activeProvider anthropic
|
|
102
|
+
zubo config set smartRouting.enabled true
|
|
103
103
|
zubo config set budget.monthlyLimitUsd 50
|
|
104
|
+
zubo config set approvals.autoApproveFirstPartyTools true
|
|
104
105
|
```
|
|
105
106
|
|
|
106
107
|
See the full [configuration reference](https://zubo.bot/docs/config.html) for all options.
|
|
@@ -167,6 +168,7 @@ Across WebChat, Telegram, Discord, Slack, and other channels:
|
|
|
167
168
|
- `/permissions set <tool> <auto|confirm|deny>` — override tool permission
|
|
168
169
|
- `/budget` — view budget usage and limits
|
|
169
170
|
- `/budget pause|resume` — pause/resume budget enforcement
|
|
171
|
+
- `/sent [n]` — show latest outbound email delivery records
|
|
170
172
|
|
|
171
173
|
## Contributing
|
|
172
174
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS sent_messages (
|
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3
|
+
provider TEXT NOT NULL, -- smtp | gmail | email_channel
|
|
4
|
+
recipient TEXT NOT NULL,
|
|
5
|
+
subject TEXT NOT NULL,
|
|
6
|
+
body_preview TEXT,
|
|
7
|
+
attachments_json TEXT,
|
|
8
|
+
status TEXT NOT NULL, -- sent | failed
|
|
9
|
+
error_message TEXT,
|
|
10
|
+
external_id TEXT,
|
|
11
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_sent_messages_created_at
|
|
15
|
+
ON sent_messages(created_at DESC);
|
|
16
|
+
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_sent_messages_recipient
|
|
18
|
+
ON sent_messages(recipient);
|
package/package.json
CHANGED
package/src/agent/loop.ts
CHANGED
|
@@ -41,12 +41,33 @@ function resolveOptions(memoriesOrOptions: string | AgentLoopOptions): AgentLoop
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** Detect standalone greetings that don't need tool definitions in context. */
|
|
44
|
-
function looksConversational(text: string): boolean {
|
|
44
|
+
function looksConversational(text: string): boolean {
|
|
45
45
|
const t = text.trim().toLowerCase().replace(/[!?.,:;]+$/g, "");
|
|
46
46
|
// Only match messages that are PURELY a greeting — no follow-up request
|
|
47
47
|
const standaloneGreetings = /^(h(ello|i|ey|owdy|ola)|yo|sup|good\s*(morning|afternoon|evening|night)|what'?s\s*up|gm|thanks|thank\s*you|ok(ay)?|bye|see\s*ya|cool|nice|wow|lol|haha)$/;
|
|
48
|
-
return standaloneGreetings.test(t);
|
|
49
|
-
}
|
|
48
|
+
return standaloneGreetings.test(t);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function hasEmailSendIntent(text: string): boolean {
|
|
52
|
+
const t = text.trim().toLowerCase();
|
|
53
|
+
if (!t) return false;
|
|
54
|
+
if (/\bdraft\b/.test(t) && !/\bsend\b/.test(t)) return false;
|
|
55
|
+
if (/\b(send|email|mail)\b/.test(t) && /\b(to|recipient)\b/.test(t)) return true;
|
|
56
|
+
if (/\b(write|compose)\b.*\b(email|mail)\b/.test(t)) return true;
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function canSendEmailWithTools(tools: any[]): boolean {
|
|
61
|
+
const names = new Set(tools.map((t) => t.name));
|
|
62
|
+
return names.has("email_send") || names.has("gmail");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasEmailSendCapability(allowedTools?: string[]): boolean {
|
|
66
|
+
const defs = getAllToolDefs();
|
|
67
|
+
if (!allowedTools) return canSendEmailWithTools(defs);
|
|
68
|
+
const allowed = new Set(allowedTools);
|
|
69
|
+
return canSendEmailWithTools(defs.filter((d) => allowed.has(d.name)));
|
|
70
|
+
}
|
|
50
71
|
|
|
51
72
|
async function prepareLoop(
|
|
52
73
|
llm: LlmProvider,
|
|
@@ -214,10 +235,12 @@ export async function agentLoop(
|
|
|
214
235
|
memoriesOrOptions: string | AgentLoopOptions = ""
|
|
215
236
|
): Promise<LoopResult> {
|
|
216
237
|
const options = resolveOptions(memoriesOrOptions);
|
|
217
|
-
const maxRounds = options.maxRounds ?? MAX_TOOL_ROUNDS;
|
|
218
|
-
const { system, messages, tools } = await prepareLoop(llm, sessionId, userMessage, options);
|
|
219
|
-
|
|
220
|
-
let
|
|
238
|
+
const maxRounds = options.maxRounds ?? MAX_TOOL_ROUNDS;
|
|
239
|
+
const { system, messages, tools } = await prepareLoop(llm, sessionId, userMessage, options);
|
|
240
|
+
const emailIntentLock = hasEmailSendIntent(userMessage) && canSendEmailWithTools(tools);
|
|
241
|
+
let emailIntentRetries = 0;
|
|
242
|
+
|
|
243
|
+
let totalToolCalls = 0;
|
|
221
244
|
|
|
222
245
|
for (let round = 0; round < maxRounds; round++) {
|
|
223
246
|
const llmStartTime = Date.now();
|
|
@@ -246,15 +269,40 @@ export async function agentLoop(
|
|
|
246
269
|
|
|
247
270
|
const toolUseBlocks = extractToolUseBlocks(response.content);
|
|
248
271
|
|
|
249
|
-
// No tool calls — done
|
|
250
|
-
if (toolUseBlocks.length === 0) {
|
|
251
|
-
const reply = response.content
|
|
252
|
-
.filter((b) => b.type === "text")
|
|
253
|
-
.map((b) => b.text ?? "")
|
|
254
|
-
.join("\n") || "";
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
272
|
+
// No tool calls — done
|
|
273
|
+
if (toolUseBlocks.length === 0) {
|
|
274
|
+
const reply = response.content
|
|
275
|
+
.filter((b) => b.type === "text")
|
|
276
|
+
.map((b) => b.text ?? "")
|
|
277
|
+
.join("\n") || "";
|
|
278
|
+
|
|
279
|
+
if (emailIntentLock && totalToolCalls === 0 && emailIntentRetries < 1) {
|
|
280
|
+
emailIntentRetries += 1;
|
|
281
|
+
messages.push({ role: "assistant", content: response.content });
|
|
282
|
+
messages.push({
|
|
283
|
+
role: "user",
|
|
284
|
+
content: [{
|
|
285
|
+
type: "text",
|
|
286
|
+
text:
|
|
287
|
+
"Policy guard: The user asked you to SEND an email. You must execute an email tool now (email_send or gmail action send/reply). " +
|
|
288
|
+
"Do not return a draft-only response.",
|
|
289
|
+
}],
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (emailIntentLock && totalToolCalls === 0 && emailIntentRetries >= 1) {
|
|
295
|
+
const lockedReply =
|
|
296
|
+
"I couldn't complete that because no email send tool was executed. " +
|
|
297
|
+
"Please try again and I'll send it directly.";
|
|
298
|
+
finishLoop(sessionId, lockedReply);
|
|
299
|
+
triggerPostLoopCompaction(llm, sessionId);
|
|
300
|
+
return { reply: lockedReply, toolCalls: totalToolCalls };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
finishLoop(sessionId, reply);
|
|
304
|
+
triggerPostLoopCompaction(llm, sessionId);
|
|
305
|
+
return { reply, toolCalls: totalToolCalls };
|
|
258
306
|
}
|
|
259
307
|
|
|
260
308
|
// Execute tools
|
|
@@ -272,18 +320,30 @@ export async function agentLoop(
|
|
|
272
320
|
return { reply: MAX_ROUNDS_FALLBACK, toolCalls: totalToolCalls };
|
|
273
321
|
}
|
|
274
322
|
|
|
275
|
-
export async function agentLoopStream(
|
|
276
|
-
llm: LlmProvider,
|
|
277
|
-
sessionId: string,
|
|
278
|
-
userMessage: string,
|
|
323
|
+
export async function agentLoopStream(
|
|
324
|
+
llm: LlmProvider,
|
|
325
|
+
sessionId: string,
|
|
326
|
+
userMessage: string,
|
|
279
327
|
callbacks: StreamCallbacks,
|
|
280
328
|
memoriesOrOptions: string | AgentLoopOptions = ""
|
|
281
|
-
): Promise<void> {
|
|
282
|
-
const options = resolveOptions(memoriesOrOptions);
|
|
283
|
-
const maxRounds = options.maxRounds ?? MAX_TOOL_ROUNDS;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
const options = resolveOptions(memoriesOrOptions);
|
|
331
|
+
const maxRounds = options.maxRounds ?? MAX_TOOL_ROUNDS;
|
|
332
|
+
const emailIntentLock = hasEmailSendIntent(userMessage) && hasEmailSendCapability(options.allowedTools);
|
|
333
|
+
|
|
334
|
+
if (emailIntentLock) {
|
|
335
|
+
try {
|
|
336
|
+
const result = await agentLoop(llm, sessionId, userMessage, options);
|
|
337
|
+
callbacks.onTextDelta(result.reply);
|
|
338
|
+
callbacks.onDone(result);
|
|
339
|
+
} catch (err: any) {
|
|
340
|
+
callbacks.onError(err);
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Fall back to non-streaming if provider doesn't support it
|
|
346
|
+
if (!llm.chatStream) {
|
|
287
347
|
try {
|
|
288
348
|
const result = await agentLoop(llm, sessionId, userMessage, memoriesOrOptions);
|
|
289
349
|
callbacks.onTextDelta(result.reply);
|
|
@@ -294,10 +354,10 @@ export async function agentLoopStream(
|
|
|
294
354
|
return;
|
|
295
355
|
}
|
|
296
356
|
|
|
297
|
-
try {
|
|
298
|
-
const { system, messages, tools } = await prepareLoop(llm, sessionId, userMessage, options);
|
|
299
|
-
|
|
300
|
-
let totalToolCalls = 0;
|
|
357
|
+
try {
|
|
358
|
+
const { system, messages, tools } = await prepareLoop(llm, sessionId, userMessage, options);
|
|
359
|
+
|
|
360
|
+
let totalToolCalls = 0;
|
|
301
361
|
let fullReply = "";
|
|
302
362
|
|
|
303
363
|
for (let round = 0; round < maxRounds; round++) {
|
package/src/agent/prompts.ts
CHANGED
|
@@ -45,11 +45,17 @@ const DEFAULT_PERSONALITY = `You are Zubo, a personal AI agent. You are friendly
|
|
|
45
45
|
- When the user says "remind me", "ping me", "follow up" — create a reminder.
|
|
46
46
|
- Use follow_ups to schedule check-ins: "follow up about the dentist tomorrow", "check in about the project in 3 days".
|
|
47
47
|
|
|
48
|
-
## Todos & notes
|
|
49
|
-
|
|
50
|
-
- Use the todos tool when the user asks to track tasks, create to-do lists, or manage action items. "Add buy groceries to my list" → todos with action "add".
|
|
51
|
-
- Use the notes tool to save, search, and organize information. "Save this recipe" → notes with action "save". "Find my notes about React" → notes with action "search".
|
|
52
|
-
- When the user mentions something they need to do, proactively offer to add it as a todo.
|
|
48
|
+
## Todos & notes
|
|
49
|
+
|
|
50
|
+
- Use the todos tool when the user asks to track tasks, create to-do lists, or manage action items. "Add buy groceries to my list" → todos with action "add".
|
|
51
|
+
- Use the notes tool to save, search, and organize information. "Save this recipe" → notes with action "save". "Find my notes about React" → notes with action "search".
|
|
52
|
+
- When the user mentions something they need to do, proactively offer to add it as a todo.
|
|
53
|
+
|
|
54
|
+
## Email actions
|
|
55
|
+
|
|
56
|
+
- If the user asks you to send/write an email to someone, you must actually send it using a tool ("email_send" or "gmail"). Do not only draft text unless the user explicitly asks for a draft.
|
|
57
|
+
- If required fields are missing, ask only for what's missing ("to", "subject", or "body") and send immediately once provided.
|
|
58
|
+
- When files are mentioned for an outgoing email, include them via the email tool's attachments input (workspace paths or upload IDs like "upload:123").
|
|
53
59
|
|
|
54
60
|
## Preferences
|
|
55
61
|
|
package/src/channels/email.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { connect, type Socket } from "net";
|
|
2
|
-
import * as tls from "tls";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
1
|
+
import { connect, type Socket } from "net";
|
|
2
|
+
import * as tls from "tls";
|
|
3
|
+
import { readFileSync, statSync } from "fs";
|
|
4
|
+
import { basename } from "path";
|
|
5
|
+
import type { ChannelAdapter, InboundMessage } from "./adapter";
|
|
6
|
+
import type { MessageRouter } from "./router";
|
|
7
|
+
import { logSentMessage } from "../email/sent-log";
|
|
8
|
+
import { logger } from "../util/logger";
|
|
6
9
|
|
|
7
10
|
export interface EmailConfig {
|
|
8
11
|
enabled: boolean;
|
|
@@ -186,11 +189,104 @@ class ImapClient {
|
|
|
186
189
|
|
|
187
190
|
// --- Minimal SMTP client ---
|
|
188
191
|
|
|
189
|
-
class SmtpClient {
|
|
192
|
+
class SmtpClient {
|
|
190
193
|
private socket: Socket | tls.TLSSocket | null = null;
|
|
191
194
|
private buffer = "";
|
|
192
195
|
|
|
193
|
-
constructor(private config: EmailConfig["smtp"], private fromName?: string) {}
|
|
196
|
+
constructor(private config: EmailConfig["smtp"], private fromName?: string) {}
|
|
197
|
+
|
|
198
|
+
private repairMojibake(value: string): string {
|
|
199
|
+
if (!/[ÃÂ]/.test(value)) return value;
|
|
200
|
+
try {
|
|
201
|
+
const repaired = Buffer.from(value, "latin1").toString("utf8");
|
|
202
|
+
return repaired.includes("�") ? value : repaired;
|
|
203
|
+
} catch {
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private encodeMimeHeader(value: string): string {
|
|
209
|
+
const repaired = this.repairMojibake(value);
|
|
210
|
+
const sanitized = repaired.replace(/[\r\n]+/g, " ").trim();
|
|
211
|
+
if (!sanitized) return "";
|
|
212
|
+
// RFC 2047 encoded-word for non-ASCII header values (emoji, accents, etc.).
|
|
213
|
+
if (/^[\x00-\x7F]*$/.test(sanitized)) return sanitized;
|
|
214
|
+
return `=?UTF-8?B?${Buffer.from(sanitized, "utf8").toString("base64")}?=`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private toCrlfBody(body: string): string {
|
|
218
|
+
const repaired = this.repairMojibake(body);
|
|
219
|
+
const normalized = repaired.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
220
|
+
return normalized
|
|
221
|
+
.split("\n")
|
|
222
|
+
.map((line) => (line.startsWith(".") ? `.${line}` : line))
|
|
223
|
+
.join("\r\n");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private buildMimeMessage(
|
|
227
|
+
fromAddr: string,
|
|
228
|
+
to: string,
|
|
229
|
+
subject: string,
|
|
230
|
+
body: string,
|
|
231
|
+
attachments: string[] = [],
|
|
232
|
+
): string {
|
|
233
|
+
const displayName = this.fromName || "Zubo";
|
|
234
|
+
const encodedFromName = this.encodeMimeHeader(displayName);
|
|
235
|
+
const encodedSubject = this.encodeMimeHeader(subject);
|
|
236
|
+
const safeBody = this.toCrlfBody(body || "");
|
|
237
|
+
if (!attachments.length) {
|
|
238
|
+
return [
|
|
239
|
+
`From: ${encodedFromName} <${fromAddr}>`,
|
|
240
|
+
`To: ${to}`,
|
|
241
|
+
`Subject: ${encodedSubject}`,
|
|
242
|
+
`Date: ${new Date().toUTCString()}`,
|
|
243
|
+
`MIME-Version: 1.0`,
|
|
244
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
245
|
+
`Content-Transfer-Encoding: 8bit`,
|
|
246
|
+
``,
|
|
247
|
+
safeBody,
|
|
248
|
+
].join("\r\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const boundary = `zubo-${crypto.randomUUID()}`;
|
|
252
|
+
const lines: string[] = [
|
|
253
|
+
`From: ${encodedFromName} <${fromAddr}>`,
|
|
254
|
+
`To: ${to}`,
|
|
255
|
+
`Subject: ${encodedSubject}`,
|
|
256
|
+
`Date: ${new Date().toUTCString()}`,
|
|
257
|
+
`MIME-Version: 1.0`,
|
|
258
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
259
|
+
``,
|
|
260
|
+
`--${boundary}`,
|
|
261
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
262
|
+
`Content-Transfer-Encoding: 8bit`,
|
|
263
|
+
``,
|
|
264
|
+
safeBody,
|
|
265
|
+
``,
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
for (const attachmentPath of attachments) {
|
|
269
|
+
const size = statSync(attachmentPath).size;
|
|
270
|
+
if (size > 10 * 1024 * 1024) {
|
|
271
|
+
throw new Error(`Attachment too large (>10MB): ${attachmentPath}`);
|
|
272
|
+
}
|
|
273
|
+
const filename = basename(attachmentPath);
|
|
274
|
+
const encodedFilename = this.encodeMimeHeader(filename);
|
|
275
|
+
const content = readFileSync(attachmentPath);
|
|
276
|
+
const b64 = content.toString("base64").replace(/(.{76})/g, "$1\r\n");
|
|
277
|
+
lines.push(
|
|
278
|
+
`--${boundary}`,
|
|
279
|
+
`Content-Type: application/octet-stream; name="${encodedFilename}"`,
|
|
280
|
+
`Content-Disposition: attachment; filename="${encodedFilename}"`,
|
|
281
|
+
`Content-Transfer-Encoding: base64`,
|
|
282
|
+
``,
|
|
283
|
+
b64,
|
|
284
|
+
``,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
lines.push(`--${boundary}--`);
|
|
288
|
+
return lines.join("\r\n");
|
|
289
|
+
}
|
|
194
290
|
|
|
195
291
|
private async connectRaw(): Promise<Socket | tls.TLSSocket> {
|
|
196
292
|
return new Promise((resolve, reject) => {
|
|
@@ -237,7 +333,7 @@ class SmtpClient {
|
|
|
237
333
|
});
|
|
238
334
|
}
|
|
239
335
|
|
|
240
|
-
async sendEmail(to: string, subject: string, body: string, from?: string): Promise<void> {
|
|
336
|
+
async sendEmail(to: string, subject: string, body: string, from?: string, attachments: string[] = []): Promise<void> {
|
|
241
337
|
const rawSocket = await this.connectRaw();
|
|
242
338
|
let socket: Socket | tls.TLSSocket = rawSocket;
|
|
243
339
|
|
|
@@ -281,18 +377,8 @@ class SmtpClient {
|
|
|
281
377
|
// DATA
|
|
282
378
|
await this.sendCmd(socket, "DATA");
|
|
283
379
|
|
|
284
|
-
// Message content — write directly to socket, not via sendCmd
|
|
285
|
-
const
|
|
286
|
-
const message = [
|
|
287
|
-
`From: ${displayName} <${fromAddr}>`,
|
|
288
|
-
`To: ${to}`,
|
|
289
|
-
`Subject: ${subject}`,
|
|
290
|
-
`Date: ${new Date().toUTCString()}`,
|
|
291
|
-
`Content-Type: text/plain; charset=utf-8`,
|
|
292
|
-
`Content-Transfer-Encoding: 8bit`,
|
|
293
|
-
``,
|
|
294
|
-
body,
|
|
295
|
-
].join("\r\n");
|
|
380
|
+
// Message content — write directly to socket, not via sendCmd
|
|
381
|
+
const message = this.buildMimeMessage(fromAddr, to, subject, body, attachments);
|
|
296
382
|
|
|
297
383
|
// Send body then terminator, wait for 250 OK
|
|
298
384
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -305,14 +391,48 @@ class SmtpClient {
|
|
|
305
391
|
});
|
|
306
392
|
|
|
307
393
|
// QUIT
|
|
308
|
-
try {
|
|
309
|
-
await this.sendCmd(socket, "QUIT");
|
|
310
|
-
} catch {}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
394
|
+
try {
|
|
395
|
+
await this.sendCmd(socket, "QUIT");
|
|
396
|
+
} catch {}
|
|
397
|
+
logSentMessage({
|
|
398
|
+
provider: "smtp",
|
|
399
|
+
recipient: to,
|
|
400
|
+
subject,
|
|
401
|
+
body,
|
|
402
|
+
attachments,
|
|
403
|
+
status: "sent",
|
|
404
|
+
});
|
|
405
|
+
} finally {
|
|
406
|
+
socket.destroy();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export async function sendSmtpEmail(
|
|
412
|
+
config: EmailConfig["smtp"],
|
|
413
|
+
to: string,
|
|
414
|
+
subject: string,
|
|
415
|
+
body: string,
|
|
416
|
+
fromName?: string,
|
|
417
|
+
from?: string,
|
|
418
|
+
attachments: string[] = [],
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
const smtp = new SmtpClient(config, fromName);
|
|
421
|
+
try {
|
|
422
|
+
await smtp.sendEmail(to, subject, body, from, attachments);
|
|
423
|
+
} catch (err: any) {
|
|
424
|
+
logSentMessage({
|
|
425
|
+
provider: "smtp",
|
|
426
|
+
recipient: to,
|
|
427
|
+
subject,
|
|
428
|
+
body,
|
|
429
|
+
attachments,
|
|
430
|
+
status: "failed",
|
|
431
|
+
errorMessage: err?.message ?? String(err),
|
|
432
|
+
});
|
|
433
|
+
throw err;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
316
436
|
|
|
317
437
|
// --- Email channel adapter ---
|
|
318
438
|
|
|
@@ -387,13 +507,21 @@ export function createEmailAdapter(
|
|
|
387
507
|
? (msg.subject.startsWith("Re:") ? msg.subject : `Re: ${msg.subject}`)
|
|
388
508
|
: "Re: your message";
|
|
389
509
|
|
|
390
|
-
try {
|
|
391
|
-
await smtp.sendEmail(senderEmail, replySubject, reply);
|
|
392
|
-
logger.info(`Email: replied to ${senderEmail}`);
|
|
393
|
-
} catch (err: any) {
|
|
394
|
-
logger.error("Email: failed to send reply", { error: err.message, to: senderEmail });
|
|
395
|
-
|
|
396
|
-
|
|
510
|
+
try {
|
|
511
|
+
await smtp.sendEmail(senderEmail, replySubject, reply);
|
|
512
|
+
logger.info(`Email: replied to ${senderEmail}`);
|
|
513
|
+
} catch (err: any) {
|
|
514
|
+
logger.error("Email: failed to send reply", { error: err.message, to: senderEmail });
|
|
515
|
+
logSentMessage({
|
|
516
|
+
provider: "email_channel",
|
|
517
|
+
recipient: senderEmail,
|
|
518
|
+
subject: replySubject,
|
|
519
|
+
body: reply,
|
|
520
|
+
status: "failed",
|
|
521
|
+
errorMessage: err?.message ?? String(err),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
});
|
|
397
525
|
|
|
398
526
|
await imap.markSeen(uid);
|
|
399
527
|
} catch (err: any) {
|
|
@@ -438,12 +566,20 @@ export function createEmailAdapter(
|
|
|
438
566
|
return;
|
|
439
567
|
}
|
|
440
568
|
|
|
441
|
-
try {
|
|
442
|
-
await smtp.sendEmail(to, "Message from Zubo", text);
|
|
443
|
-
logger.info(`Email: sent proactive message to ${to}`);
|
|
444
|
-
} catch (err: any) {
|
|
445
|
-
logger.error("Email: failed to send proactive message", { error: err.message, to });
|
|
446
|
-
|
|
569
|
+
try {
|
|
570
|
+
await smtp.sendEmail(to, "Message from Zubo", text);
|
|
571
|
+
logger.info(`Email: sent proactive message to ${to}`);
|
|
572
|
+
} catch (err: any) {
|
|
573
|
+
logger.error("Email: failed to send proactive message", { error: err.message, to });
|
|
574
|
+
logSentMessage({
|
|
575
|
+
provider: "email_channel",
|
|
576
|
+
recipient: to,
|
|
577
|
+
subject: "Message from Zubo",
|
|
578
|
+
body: text,
|
|
579
|
+
status: "failed",
|
|
580
|
+
errorMessage: err?.message ?? String(err),
|
|
581
|
+
});
|
|
582
|
+
}
|
|
447
583
|
},
|
|
448
584
|
};
|
|
449
585
|
}
|
package/src/channels/router.ts
CHANGED
|
@@ -223,6 +223,7 @@ export function createRouter(
|
|
|
223
223
|
"/permissions set <tool> <auto|confirm|deny>\n" +
|
|
224
224
|
"/budget\n" +
|
|
225
225
|
"/budget pause|resume\n" +
|
|
226
|
+
"/sent [n]\n" +
|
|
226
227
|
"\n" +
|
|
227
228
|
"Docs: https://zubo.bot/docs/";
|
|
228
229
|
|
|
@@ -313,6 +314,31 @@ export function createRouter(
|
|
|
313
314
|
`monthly: $${monthly.total.toFixed(4)} / ${row.monthly_limit_usd ? `$${row.monthly_limit_usd.toFixed(2)}` : "unlimited"}`
|
|
314
315
|
);
|
|
315
316
|
}
|
|
317
|
+
if (command.name === "sent") {
|
|
318
|
+
const limitRaw = Number(command.args.trim() || "10");
|
|
319
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(50, Math.floor(limitRaw))) : 10;
|
|
320
|
+
try {
|
|
321
|
+
const rows = db.query(
|
|
322
|
+
"SELECT provider, recipient, subject, status, error_message, created_at FROM sent_messages ORDER BY id DESC LIMIT ?"
|
|
323
|
+
).all(limit) as Array<{
|
|
324
|
+
provider: string;
|
|
325
|
+
recipient: string;
|
|
326
|
+
subject: string;
|
|
327
|
+
status: string;
|
|
328
|
+
error_message: string | null;
|
|
329
|
+
created_at: string;
|
|
330
|
+
}>;
|
|
331
|
+
if (!rows.length) return "No sent email records yet.";
|
|
332
|
+
return rows
|
|
333
|
+
.map((r, i) =>
|
|
334
|
+
`[${i + 1}] ${r.created_at} ${r.status.toUpperCase()} via ${r.provider} -> ${r.recipient}\n` +
|
|
335
|
+
`Subject: ${r.subject}${r.error_message ? `\nError: ${r.error_message}` : ""}`
|
|
336
|
+
)
|
|
337
|
+
.join("\n\n");
|
|
338
|
+
} catch {
|
|
339
|
+
return "Sent log is not available yet. Restart Zubo to apply latest DB migrations.";
|
|
340
|
+
}
|
|
341
|
+
}
|
|
316
342
|
return null;
|
|
317
343
|
}
|
|
318
344
|
|
package/src/channels/webchat.ts
CHANGED
|
@@ -360,12 +360,29 @@ async function handleDashboardApi(url: URL, req: Request): Promise<Response | nu
|
|
|
360
360
|
return Response.json({ content: lines.slice(-100).join("\n") });
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
// GET /api/dashboard/skills
|
|
364
|
-
if (path === "/skills" && req.method === "GET") {
|
|
365
|
-
return Response.json({ skills: getSkillsData() });
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
//
|
|
363
|
+
// GET /api/dashboard/skills
|
|
364
|
+
if (path === "/skills" && req.method === "GET") {
|
|
365
|
+
return Response.json({ skills: getSkillsData() });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// GET /api/dashboard/sent-messages?limit=20
|
|
369
|
+
if (path === "/sent-messages" && req.method === "GET") {
|
|
370
|
+
const db = getDb();
|
|
371
|
+
const limitRaw = Number(url.searchParams.get("limit") || "20");
|
|
372
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(100, Math.floor(limitRaw))) : 20;
|
|
373
|
+
try {
|
|
374
|
+
const rows = db.query(
|
|
375
|
+
`SELECT id, provider, recipient, subject, body_preview, attachments_json, status, error_message, external_id, created_at
|
|
376
|
+
FROM sent_messages
|
|
377
|
+
ORDER BY id DESC LIMIT ?`
|
|
378
|
+
).all(limit);
|
|
379
|
+
return Response.json({ rows });
|
|
380
|
+
} catch (err: any) {
|
|
381
|
+
return Response.json({ rows: [], error: err.message });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── TODOS ──
|
|
369
386
|
|
|
370
387
|
// GET /api/dashboard/todos?filter=pending|done|all
|
|
371
388
|
if (path === "/todos" && req.method === "GET") {
|
package/src/config/schema.ts
CHANGED
|
@@ -144,6 +144,12 @@ export const configSchema = z.object({
|
|
|
144
144
|
// Tool permission overrides
|
|
145
145
|
toolPermissions: z.record(z.string(), z.enum(["auto", "confirm", "deny"])).optional(),
|
|
146
146
|
|
|
147
|
+
// Approval behavior
|
|
148
|
+
approvals: z.object({
|
|
149
|
+
// If true, built-in first-party tools never require secondary confirmation prompts.
|
|
150
|
+
autoApproveFirstPartyTools: z.boolean().default(false),
|
|
151
|
+
}).optional(),
|
|
152
|
+
|
|
147
153
|
// Memory retrieval tuning for router context injection
|
|
148
154
|
memoryRetrieval: z.object({
|
|
149
155
|
contextTopK: z.number().int().min(1).max(10).default(3),
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getDb } from "../db/connection";
|
|
2
|
+
import { logger } from "../util/logger";
|
|
3
|
+
|
|
4
|
+
export interface SentMessageLogEntry {
|
|
5
|
+
provider: string;
|
|
6
|
+
recipient: string;
|
|
7
|
+
subject: string;
|
|
8
|
+
body?: string;
|
|
9
|
+
attachments?: string[];
|
|
10
|
+
status: "sent" | "failed";
|
|
11
|
+
errorMessage?: string;
|
|
12
|
+
externalId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function logSentMessage(entry: SentMessageLogEntry): void {
|
|
16
|
+
try {
|
|
17
|
+
const db = getDb();
|
|
18
|
+
const preview = (entry.body ?? "").slice(0, 500);
|
|
19
|
+
const attachmentsJson = entry.attachments?.length ? JSON.stringify(entry.attachments) : null;
|
|
20
|
+
db.prepare(
|
|
21
|
+
`INSERT INTO sent_messages
|
|
22
|
+
(provider, recipient, subject, body_preview, attachments_json, status, error_message, external_id)
|
|
23
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
24
|
+
).run(
|
|
25
|
+
entry.provider,
|
|
26
|
+
entry.recipient,
|
|
27
|
+
entry.subject,
|
|
28
|
+
preview || null,
|
|
29
|
+
attachmentsJson,
|
|
30
|
+
entry.status,
|
|
31
|
+
entry.errorMessage ?? null,
|
|
32
|
+
entry.externalId ?? null,
|
|
33
|
+
);
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
logger.warn("Failed to log sent message", { error: err?.message ?? String(err) });
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/start.ts
CHANGED
|
@@ -30,8 +30,9 @@ import { initMemory } from "./memory/engine";
|
|
|
30
30
|
import { registerTodosTool } from "./tools/builtin/todos";
|
|
31
31
|
import { registerNotesTool } from "./tools/builtin/notes";
|
|
32
32
|
import { registerPreferencesTool } from "./tools/builtin/preferences";
|
|
33
|
-
import { registerTopicsTool } from "./tools/builtin/topics";
|
|
34
|
-
import {
|
|
33
|
+
import { registerTopicsTool } from "./tools/builtin/topics";
|
|
34
|
+
import { registerEmailSendTool } from "./tools/builtin/email-send";
|
|
35
|
+
import { logger, enableFileLogging } from "./util/logger";
|
|
35
36
|
|
|
36
37
|
function openBrowser(url: string) {
|
|
37
38
|
try {
|
|
@@ -234,12 +235,13 @@ export async function startZubo(isDaemon = false) {
|
|
|
234
235
|
registerManageTriggersTool();
|
|
235
236
|
|
|
236
237
|
// Register personal agent tools
|
|
237
|
-
registerTodosTool();
|
|
238
|
-
registerNotesTool();
|
|
239
|
-
registerPreferencesTool();
|
|
240
|
-
registerTopicsTool();
|
|
241
|
-
|
|
242
|
-
registerFollowUpsTool
|
|
238
|
+
registerTodosTool();
|
|
239
|
+
registerNotesTool();
|
|
240
|
+
registerPreferencesTool();
|
|
241
|
+
registerTopicsTool();
|
|
242
|
+
registerEmailSendTool();
|
|
243
|
+
const { registerFollowUpsTool } = await import("./tools/builtin/follow-ups");
|
|
244
|
+
registerFollowUpsTool(db, router, config, llm);
|
|
243
245
|
|
|
244
246
|
// Register code interpreter tool
|
|
245
247
|
if (config.codeInterpreter?.enabled !== false) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { registerTool } from "../registry";
|
|
4
|
+
import { paths } from "../../config/paths";
|
|
5
|
+
import { getDb } from "../../db/connection";
|
|
6
|
+
import { sendSmtpEmail, type EmailConfig } from "../../channels/email";
|
|
7
|
+
|
|
8
|
+
function isValidEmail(value: string): boolean {
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resolveAttachmentRef(ref: string): string | null {
|
|
14
|
+
const trimmed = ref.trim();
|
|
15
|
+
if (!trimmed) return null;
|
|
16
|
+
if (trimmed.startsWith("upload:")) {
|
|
17
|
+
const id = Number(trimmed.slice("upload:".length));
|
|
18
|
+
if (!Number.isFinite(id) || id <= 0) return null;
|
|
19
|
+
try {
|
|
20
|
+
const db = getDb();
|
|
21
|
+
const row = db.query("SELECT filename FROM uploads WHERE id = ?").get(id) as { filename: string } | null;
|
|
22
|
+
if (!row?.filename) return null;
|
|
23
|
+
return row.filename;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return trimmed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isAllowedAttachmentPath(filePath: string): boolean {
|
|
32
|
+
const absolute = resolve(filePath);
|
|
33
|
+
const roots = [resolve(process.cwd()), resolve(paths.uploads)];
|
|
34
|
+
return roots.some((root) => absolute.startsWith(root + "/") || absolute === root);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerEmailSendTool(): void {
|
|
38
|
+
registerTool({
|
|
39
|
+
definition: {
|
|
40
|
+
name: "email_send",
|
|
41
|
+
description:
|
|
42
|
+
"Send an email using the configured SMTP account in channels.email.smtp. " +
|
|
43
|
+
"Use this when the user asks to send or write an email to someone.",
|
|
44
|
+
input_schema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
to: { type: "string", description: "Recipient email address" },
|
|
48
|
+
subject: { type: "string", description: "Email subject" },
|
|
49
|
+
body: { type: "string", description: "Email body content" },
|
|
50
|
+
attachments: {
|
|
51
|
+
type: "array",
|
|
52
|
+
items: { type: "string" },
|
|
53
|
+
description:
|
|
54
|
+
"Optional attachment references. Use absolute/relative file paths in workspace, or upload IDs via `upload:<id>`.",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
required: ["to", "subject", "body"],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
execute: async (input) => {
|
|
61
|
+
const to = String(input.to ?? "").trim();
|
|
62
|
+
const subject = String(input.subject ?? "").trim();
|
|
63
|
+
const body = String(input.body ?? "");
|
|
64
|
+
const attachmentRefs = Array.isArray(input.attachments)
|
|
65
|
+
? input.attachments.map((x) => String(x))
|
|
66
|
+
: [];
|
|
67
|
+
|
|
68
|
+
if (!to || !isValidEmail(to)) {
|
|
69
|
+
return "Invalid recipient email address. Please provide a valid `to` address.";
|
|
70
|
+
}
|
|
71
|
+
if (!subject) return "Subject is required.";
|
|
72
|
+
if (!body.trim()) return "Body is required.";
|
|
73
|
+
|
|
74
|
+
let parsed: any;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return "Could not read config. Run `zubo setup` and configure Email first.";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const emailCfg = parsed?.channels?.email as EmailConfig | undefined;
|
|
82
|
+
const smtpCfg = emailCfg?.smtp;
|
|
83
|
+
if (!smtpCfg?.host || !smtpCfg?.user || !smtpCfg?.password) {
|
|
84
|
+
return (
|
|
85
|
+
"Email is not configured yet. Configure `channels.email.smtp` in Settings > Channels > Email " +
|
|
86
|
+
"or run `zubo setup`."
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const attachments: string[] = [];
|
|
91
|
+
for (const ref of attachmentRefs) {
|
|
92
|
+
const resolvedRef = resolveAttachmentRef(ref);
|
|
93
|
+
if (!resolvedRef) return `Invalid attachment reference: ${ref}`;
|
|
94
|
+
const absolute = resolve(resolvedRef);
|
|
95
|
+
if (!existsSync(absolute)) return `Attachment not found: ${ref}`;
|
|
96
|
+
if (!isAllowedAttachmentPath(absolute)) {
|
|
97
|
+
return `Attachment path is outside allowed roots (workspace/uploads): ${ref}`;
|
|
98
|
+
}
|
|
99
|
+
attachments.push(absolute);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await sendSmtpEmail(smtpCfg, to, subject, body, emailCfg?.fromName, undefined, attachments);
|
|
104
|
+
return attachments.length
|
|
105
|
+
? `Email sent to ${to} with ${attachments.length} attachment(s).`
|
|
106
|
+
: `Email sent to ${to} with subject "${subject}".`;
|
|
107
|
+
} catch (err: any) {
|
|
108
|
+
return `Failed to send email: ${err?.message ?? String(err)}`;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
import { logSentMessage } from "../../../../email/sent-log";
|
|
2
|
+
const API = "https://gmail.googleapis.com/gmail/v1/users/me";
|
|
3
|
+
|
|
4
|
+
function encodeMimeHeader(value: string): string {
|
|
5
|
+
const raw = String(value || "");
|
|
6
|
+
let repaired = raw;
|
|
7
|
+
if (/[ÃÂ]/.test(raw)) {
|
|
8
|
+
try {
|
|
9
|
+
const candidate = Buffer.from(raw, "latin1").toString("utf8");
|
|
10
|
+
repaired = candidate.includes("�") ? raw : candidate;
|
|
11
|
+
} catch {
|
|
12
|
+
repaired = raw;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const sanitized = repaired.replace(/[\r\n]+/g, " ").trim();
|
|
16
|
+
if (!sanitized) return "";
|
|
17
|
+
if (/^[\x00-\x7F]*$/.test(sanitized)) return sanitized;
|
|
18
|
+
return `=?UTF-8?B?${Buffer.from(sanitized, "utf8").toString("base64")}?=`;
|
|
19
|
+
}
|
|
2
20
|
|
|
3
21
|
async function getToken(): Promise<string> {
|
|
4
22
|
// Try the new multi-provider OAuth system first
|
|
@@ -82,18 +100,39 @@ export default async function (input: Record<string, unknown>): Promise<string>
|
|
|
82
100
|
}
|
|
83
101
|
return JSON.stringify({ id: data.id, subject: getHeader("Subject"), from: getHeader("From"), date: getHeader("Date"), body: emailBody });
|
|
84
102
|
}
|
|
85
|
-
case "send": {
|
|
86
|
-
if (!to) return JSON.stringify({ error: "to is required" });
|
|
87
|
-
if (!subject) return JSON.stringify({ error: "subject is required" });
|
|
88
|
-
const raw = Buffer.from(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
case "send": {
|
|
104
|
+
if (!to) return JSON.stringify({ error: "to is required" });
|
|
105
|
+
if (!subject) return JSON.stringify({ error: "subject is required" });
|
|
106
|
+
const raw = Buffer.from(
|
|
107
|
+
`MIME-Version: 1.0\r\nTo: ${to}\r\nSubject: ${encodeMimeHeader(subject)}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body || ""}`
|
|
108
|
+
).toString("base64url");
|
|
109
|
+
const res = await fetch(`${API}/messages/send`, {
|
|
110
|
+
method: "POST", headers,
|
|
111
|
+
body: JSON.stringify({ raw }),
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const error = await safeApiErr(res, "Gmail");
|
|
115
|
+
logSentMessage({
|
|
116
|
+
provider: "gmail",
|
|
117
|
+
recipient: to,
|
|
118
|
+
subject,
|
|
119
|
+
body: body || "",
|
|
120
|
+
status: "failed",
|
|
121
|
+
errorMessage: error,
|
|
122
|
+
});
|
|
123
|
+
return error;
|
|
124
|
+
}
|
|
125
|
+
const data = (await res.json()) as any;
|
|
126
|
+
logSentMessage({
|
|
127
|
+
provider: "gmail",
|
|
128
|
+
recipient: to,
|
|
129
|
+
subject,
|
|
130
|
+
body: body || "",
|
|
131
|
+
status: "sent",
|
|
132
|
+
externalId: data.id,
|
|
133
|
+
});
|
|
134
|
+
return JSON.stringify({ sent: true, id: data.id });
|
|
135
|
+
}
|
|
97
136
|
case "search": {
|
|
98
137
|
if (!query) return JSON.stringify({ error: "query is required" });
|
|
99
138
|
const res = await fetch(`${API}/messages?q=${encodeURIComponent(query)}&maxResults=${max_results || 10}`, { headers });
|
|
@@ -107,24 +146,55 @@ export default async function (input: Record<string, unknown>): Promise<string>
|
|
|
107
146
|
const orig = await fetch(`${API}/messages/${message_id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Message-ID`, { headers });
|
|
108
147
|
if (!orig.ok) return await safeApiErr(orig, "Gmail");
|
|
109
148
|
const origData = (await orig.json()) as any;
|
|
110
|
-
const getHeader = (name: string) => origData.payload?.headers?.find((h: any) => h.name === name)?.value ?? "";
|
|
111
|
-
const replyTo = getHeader("From");
|
|
112
|
-
const subj = getHeader("Subject").startsWith("Re:") ? getHeader("Subject") : `Re: ${getHeader("Subject")}`;
|
|
113
|
-
const msgId = getHeader("Message-ID");
|
|
114
|
-
const raw = Buffer.from(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
149
|
+
const getHeader = (name: string) => origData.payload?.headers?.find((h: any) => h.name === name)?.value ?? "";
|
|
150
|
+
const replyTo = getHeader("From");
|
|
151
|
+
const subj = getHeader("Subject").startsWith("Re:") ? getHeader("Subject") : `Re: ${getHeader("Subject")}`;
|
|
152
|
+
const msgId = getHeader("Message-ID");
|
|
153
|
+
const raw = Buffer.from(
|
|
154
|
+
`MIME-Version: 1.0\r\nTo: ${replyTo}\r\nSubject: ${encodeMimeHeader(subj)}\r\nIn-Reply-To: ${msgId}\r\nReferences: ${msgId}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body}`
|
|
155
|
+
).toString("base64url");
|
|
156
|
+
const res = await fetch(`${API}/messages/send`, {
|
|
157
|
+
method: "POST", headers,
|
|
158
|
+
body: JSON.stringify({ raw, threadId: origData.threadId }),
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const error = await safeApiErr(res, "Gmail");
|
|
162
|
+
logSentMessage({
|
|
163
|
+
provider: "gmail",
|
|
164
|
+
recipient: replyTo,
|
|
165
|
+
subject: subj,
|
|
166
|
+
body,
|
|
167
|
+
status: "failed",
|
|
168
|
+
errorMessage: error,
|
|
169
|
+
});
|
|
170
|
+
return error;
|
|
171
|
+
}
|
|
172
|
+
const data = (await res.json()) as any;
|
|
173
|
+
logSentMessage({
|
|
174
|
+
provider: "gmail",
|
|
175
|
+
recipient: replyTo,
|
|
176
|
+
subject: subj,
|
|
177
|
+
body,
|
|
178
|
+
status: "sent",
|
|
179
|
+
externalId: data.id,
|
|
180
|
+
});
|
|
181
|
+
return JSON.stringify({ replied: true, id: data.id });
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
return JSON.stringify({ error: `Unknown action: ${action}` });
|
|
185
|
+
}
|
|
186
|
+
} catch (err: any) {
|
|
187
|
+
console.error(`[Gmail] Request failed: ${err.message}`);
|
|
188
|
+
if (action === "send" || action === "reply") {
|
|
189
|
+
logSentMessage({
|
|
190
|
+
provider: "gmail",
|
|
191
|
+
recipient: String(to || ""),
|
|
192
|
+
subject: String(subject || ""),
|
|
193
|
+
body: body || "",
|
|
194
|
+
status: "failed",
|
|
195
|
+
errorMessage: err?.message ?? String(err),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return JSON.stringify({ error: "Gmail request failed. Check logs for details." });
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/tools/executor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getTool } from "./registry";
|
|
1
|
+
import { getTool, isUserInstalledSkill } from "./registry";
|
|
2
2
|
import { getToolPermission, getToolScopes, hasRiskyScope } from "./permissions";
|
|
3
3
|
import { logger } from "../util/logger";
|
|
4
4
|
import { recordError } from "../util/error-buffer";
|
|
@@ -21,6 +21,7 @@ const BUILTIN_INTEGRATION_TOOLS = new Set([
|
|
|
21
21
|
interface ToolScopePolicy {
|
|
22
22
|
allowed?: string[];
|
|
23
23
|
dryRunByDefault: boolean;
|
|
24
|
+
autoApproveFirstPartyTools: boolean;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const toolScopePolicyCache: {
|
|
@@ -28,7 +29,7 @@ const toolScopePolicyCache: {
|
|
|
28
29
|
value: ToolScopePolicy;
|
|
29
30
|
} = {
|
|
30
31
|
mtimeMs: -1,
|
|
31
|
-
value: { dryRunByDefault: false },
|
|
32
|
+
value: { dryRunByDefault: false, autoApproveFirstPartyTools: false },
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
export interface ToolResult {
|
|
@@ -77,14 +78,20 @@ function readToolScopePolicy(): ToolScopePolicy {
|
|
|
77
78
|
const parsed = {
|
|
78
79
|
allowed: Array.isArray(cfg?.toolScopes?.allowed) ? cfg.toolScopes.allowed : undefined,
|
|
79
80
|
dryRunByDefault: Boolean(cfg?.toolScopes?.dryRunByDefault),
|
|
81
|
+
autoApproveFirstPartyTools: cfg?.approvals?.autoApproveFirstPartyTools === true,
|
|
80
82
|
};
|
|
81
83
|
toolScopePolicyCache.mtimeMs = mtimeMs;
|
|
82
84
|
toolScopePolicyCache.value = parsed;
|
|
83
85
|
return parsed;
|
|
84
86
|
} catch {
|
|
85
|
-
return { dryRunByDefault: false };
|
|
87
|
+
return { dryRunByDefault: false, autoApproveFirstPartyTools: false };
|
|
86
88
|
}
|
|
87
89
|
}
|
|
90
|
+
|
|
91
|
+
function isFirstPartyTool(name: string): boolean {
|
|
92
|
+
if (name.includes("__")) return false; // MCP tools
|
|
93
|
+
return !isUserInstalledSkill(name);
|
|
94
|
+
}
|
|
88
95
|
|
|
89
96
|
// Determine if a tool should run in the sandbox (user-installed skills only)
|
|
90
97
|
async function shouldSandbox(
|
|
@@ -235,6 +242,9 @@ export async function executeTool(
|
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
if (permission === "confirm") {
|
|
245
|
+
if (scopePolicy.autoApproveFirstPartyTools && isFirstPartyTool(name)) {
|
|
246
|
+
logger.info(`Auto-approving first-party tool: ${name}`);
|
|
247
|
+
} else
|
|
238
248
|
// For direct user requests, don't require a second explicit approval round.
|
|
239
249
|
if (options.directUserRequest) {
|
|
240
250
|
logger.info(`Bypassing confirmation for direct user request: ${name}`);
|
package/src/tools/permissions.ts
CHANGED
|
@@ -17,7 +17,8 @@ export type ToolScope =
|
|
|
17
17
|
|
|
18
18
|
const DEFAULT_PERMISSIONS: Record<string, ToolPermission> = {
|
|
19
19
|
// Built-in tools — always safe
|
|
20
|
-
datetime: "auto",
|
|
20
|
+
datetime: "auto",
|
|
21
|
+
get_current_datetime: "auto",
|
|
21
22
|
memory_write: "auto",
|
|
22
23
|
memory_search: "auto",
|
|
23
24
|
memory_prune: "confirm",
|
|
@@ -53,8 +54,9 @@ const DEFAULT_PERMISSIONS: Record<string, ToolPermission> = {
|
|
|
53
54
|
todos: "auto",
|
|
54
55
|
notes: "auto",
|
|
55
56
|
preferences: "auto",
|
|
56
|
-
topics: "auto",
|
|
57
|
-
follow_ups: "auto",
|
|
57
|
+
topics: "auto",
|
|
58
|
+
follow_ups: "auto",
|
|
59
|
+
email_send: "confirm",
|
|
58
60
|
|
|
59
61
|
// Built-in skills — safe (read-only or low risk)
|
|
60
62
|
web_search: "auto",
|
|
@@ -120,6 +122,7 @@ function getPermissionOverrides(): Record<string, ToolPermission> {
|
|
|
120
122
|
|
|
121
123
|
const TOOL_SCOPES: Record<string, ToolScope[]> = {
|
|
122
124
|
datetime: ["memory"],
|
|
125
|
+
get_current_datetime: ["memory"],
|
|
123
126
|
memory_write: ["memory"],
|
|
124
127
|
memory_search: ["memory"],
|
|
125
128
|
memory_prune: ["memory"],
|
|
@@ -145,6 +148,7 @@ const TOOL_SCOPES: Record<string, ToolScope[]> = {
|
|
|
145
148
|
preferences: ["memory"],
|
|
146
149
|
topics: ["memory"],
|
|
147
150
|
follow_ups: ["memory"],
|
|
151
|
+
email_send: ["network_write"],
|
|
148
152
|
web_search: ["network_read"],
|
|
149
153
|
url_fetch: ["network_read"],
|
|
150
154
|
file_read: ["filesystem_read"],
|