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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zubo",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "Your AI agent that never forgets. Persistent memory, 25+ tools, 7 channels, 11+ LLM providers — runs entirely on your machine.",
5
5
  "license": "MIT",
6
6
  "author": "thomaskanze",
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 totalToolCalls = 0;
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
- finishLoop(sessionId, reply);
256
- triggerPostLoopCompaction(llm, sessionId);
257
- return { reply, toolCalls: totalToolCalls };
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
- // Fall back to non-streaming if provider doesn't support it
286
- if (!llm.chatStream) {
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++) {
@@ -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
 
@@ -1,8 +1,11 @@
1
- import { connect, type Socket } from "net";
2
- import * as tls from "tls";
3
- import type { ChannelAdapter, InboundMessage } from "./adapter";
4
- import type { MessageRouter } from "./router";
5
- import { logger } from "../util/logger";
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 displayName = this.fromName || "Zubo";
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
- } finally {
312
- socket.destroy();
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
  }
@@ -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
 
@@ -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
- // ── TODOS ──
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") {
@@ -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 { logger, enableFileLogging } from "./util/logger";
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
- const { registerFollowUpsTool } = await import("./tools/builtin/follow-ups");
242
- registerFollowUpsTool(db, router, config, llm);
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
- const API = "https://gmail.googleapis.com/gmail/v1/users/me";
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(`MIME-Version: 1.0\r\nTo: ${to}\r\nSubject: ${subject}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body || ""}`).toString("base64url");
89
- const res = await fetch(`${API}/messages/send`, {
90
- method: "POST", headers,
91
- body: JSON.stringify({ raw }),
92
- });
93
- if (!res.ok) return await safeApiErr(res, "Gmail");
94
- const data = (await res.json()) as any;
95
- return JSON.stringify({ sent: true, id: data.id });
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(`MIME-Version: 1.0\r\nTo: ${replyTo}\r\nSubject: ${subj}\r\nIn-Reply-To: ${msgId}\r\nReferences: ${msgId}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body}`).toString("base64url");
115
- const res = await fetch(`${API}/messages/send`, {
116
- method: "POST", headers,
117
- body: JSON.stringify({ raw, threadId: origData.threadId }),
118
- });
119
- if (!res.ok) return await safeApiErr(res, "Gmail");
120
- const data = (await res.json()) as any;
121
- return JSON.stringify({ replied: true, id: data.id });
122
- }
123
- default:
124
- return JSON.stringify({ error: `Unknown action: ${action}` });
125
- }
126
- } catch (err: any) {
127
- console.error(`[Gmail] Request failed: ${err.message}`);
128
- return JSON.stringify({ error: "Gmail request failed. Check logs for details." });
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
+ }
@@ -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}`);
@@ -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"],