zubo 0.1.27 → 0.1.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zubo",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
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",
@@ -45,11 +45,16 @@ 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.
53
58
 
54
59
  ## Preferences
55
60
 
@@ -186,11 +186,27 @@ class ImapClient {
186
186
 
187
187
  // --- Minimal SMTP client ---
188
188
 
189
- class SmtpClient {
189
+ class SmtpClient {
190
190
  private socket: Socket | tls.TLSSocket | null = null;
191
191
  private buffer = "";
192
192
 
193
- constructor(private config: EmailConfig["smtp"], private fromName?: string) {}
193
+ constructor(private config: EmailConfig["smtp"], private fromName?: string) {}
194
+
195
+ private encodeMimeHeader(value: string): string {
196
+ const sanitized = value.replace(/[\r\n]+/g, " ").trim();
197
+ if (!sanitized) return "";
198
+ // RFC 2047 encoded-word for non-ASCII header values (emoji, accents, etc.).
199
+ if (/^[\x00-\x7F]*$/.test(sanitized)) return sanitized;
200
+ return `=?UTF-8?B?${Buffer.from(sanitized, "utf8").toString("base64")}?=`;
201
+ }
202
+
203
+ private toCrlfBody(body: string): string {
204
+ const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
205
+ return normalized
206
+ .split("\n")
207
+ .map((line) => (line.startsWith(".") ? `.${line}` : line))
208
+ .join("\r\n");
209
+ }
194
210
 
195
211
  private async connectRaw(): Promise<Socket | tls.TLSSocket> {
196
212
  return new Promise((resolve, reject) => {
@@ -237,7 +253,7 @@ class SmtpClient {
237
253
  });
238
254
  }
239
255
 
240
- async sendEmail(to: string, subject: string, body: string, from?: string): Promise<void> {
256
+ async sendEmail(to: string, subject: string, body: string, from?: string): Promise<void> {
241
257
  const rawSocket = await this.connectRaw();
242
258
  let socket: Socket | tls.TLSSocket = rawSocket;
243
259
 
@@ -282,17 +298,21 @@ class SmtpClient {
282
298
  await this.sendCmd(socket, "DATA");
283
299
 
284
300
  // 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");
301
+ const displayName = this.fromName || "Zubo";
302
+ const encodedFromName = this.encodeMimeHeader(displayName);
303
+ const encodedSubject = this.encodeMimeHeader(subject);
304
+ const safeBody = this.toCrlfBody(body || "");
305
+ const message = [
306
+ `From: ${encodedFromName} <${fromAddr}>`,
307
+ `To: ${to}`,
308
+ `Subject: ${encodedSubject}`,
309
+ `Date: ${new Date().toUTCString()}`,
310
+ `MIME-Version: 1.0`,
311
+ `Content-Type: text/plain; charset=utf-8`,
312
+ `Content-Transfer-Encoding: 8bit`,
313
+ ``,
314
+ safeBody,
315
+ ].join("\r\n");
296
316
 
297
317
  // Send body then terminator, wait for 250 OK
298
318
  await new Promise<void>((resolve, reject) => {
@@ -312,7 +332,19 @@ class SmtpClient {
312
332
  socket.destroy();
313
333
  }
314
334
  }
315
- }
335
+ }
336
+
337
+ export async function sendSmtpEmail(
338
+ config: EmailConfig["smtp"],
339
+ to: string,
340
+ subject: string,
341
+ body: string,
342
+ fromName?: string,
343
+ from?: string,
344
+ ): Promise<void> {
345
+ const smtp = new SmtpClient(config, fromName);
346
+ await smtp.sendEmail(to, subject, body, from);
347
+ }
316
348
 
317
349
  // --- Email channel adapter ---
318
350
 
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,63 @@
1
+ import { readFileSync } from "fs";
2
+ import { registerTool } from "../registry";
3
+ import { paths } from "../../config/paths";
4
+ import { sendSmtpEmail, type EmailConfig } from "../../channels/email";
5
+
6
+ function isValidEmail(value: string): boolean {
7
+ const trimmed = value.trim();
8
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
9
+ }
10
+
11
+ export function registerEmailSendTool(): void {
12
+ registerTool({
13
+ definition: {
14
+ name: "email_send",
15
+ description:
16
+ "Send an email using the configured SMTP account in channels.email.smtp. " +
17
+ "Use this when the user asks to send or write an email to someone.",
18
+ input_schema: {
19
+ type: "object",
20
+ properties: {
21
+ to: { type: "string", description: "Recipient email address" },
22
+ subject: { type: "string", description: "Email subject" },
23
+ body: { type: "string", description: "Email body content" },
24
+ },
25
+ required: ["to", "subject", "body"],
26
+ },
27
+ },
28
+ execute: async (input) => {
29
+ const to = String(input.to ?? "").trim();
30
+ const subject = String(input.subject ?? "").trim();
31
+ const body = String(input.body ?? "");
32
+
33
+ if (!to || !isValidEmail(to)) {
34
+ return "Invalid recipient email address. Please provide a valid `to` address.";
35
+ }
36
+ if (!subject) return "Subject is required.";
37
+ if (!body.trim()) return "Body is required.";
38
+
39
+ let parsed: any;
40
+ try {
41
+ parsed = JSON.parse(readFileSync(paths.config, "utf-8"));
42
+ } catch {
43
+ return "Could not read config. Run `zubo setup` and configure Email first.";
44
+ }
45
+
46
+ const emailCfg = parsed?.channels?.email as EmailConfig | undefined;
47
+ const smtpCfg = emailCfg?.smtp;
48
+ if (!smtpCfg?.host || !smtpCfg?.user || !smtpCfg?.password) {
49
+ return (
50
+ "Email is not configured yet. Configure `channels.email.smtp` in Settings > Channels > Email " +
51
+ "or run `zubo setup`."
52
+ );
53
+ }
54
+
55
+ try {
56
+ await sendSmtpEmail(smtpCfg, to, subject, body, emailCfg?.fromName);
57
+ return `Email sent to ${to} with subject "${subject}".`;
58
+ } catch (err: any) {
59
+ return `Failed to send email: ${err?.message ?? String(err)}`;
60
+ }
61
+ },
62
+ });
63
+ }
@@ -1,4 +1,11 @@
1
- const API = "https://gmail.googleapis.com/gmail/v1/users/me";
1
+ const API = "https://gmail.googleapis.com/gmail/v1/users/me";
2
+
3
+ function encodeMimeHeader(value: string): string {
4
+ const sanitized = String(value || "").replace(/[\r\n]+/g, " ").trim();
5
+ if (!sanitized) return "";
6
+ if (/^[\x00-\x7F]*$/.test(sanitized)) return sanitized;
7
+ return `=?UTF-8?B?${Buffer.from(sanitized, "utf8").toString("base64")}?=`;
8
+ }
2
9
 
3
10
  async function getToken(): Promise<string> {
4
11
  // Try the new multi-provider OAuth system first
@@ -82,13 +89,15 @@ export default async function (input: Record<string, unknown>): Promise<string>
82
89
  }
83
90
  return JSON.stringify({ id: data.id, subject: getHeader("Subject"), from: getHeader("From"), date: getHeader("Date"), body: emailBody });
84
91
  }
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
+ case "send": {
93
+ if (!to) return JSON.stringify({ error: "to is required" });
94
+ if (!subject) return JSON.stringify({ error: "subject is required" });
95
+ const raw = Buffer.from(
96
+ `MIME-Version: 1.0\r\nTo: ${to}\r\nSubject: ${encodeMimeHeader(subject)}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body || ""}`
97
+ ).toString("base64url");
98
+ const res = await fetch(`${API}/messages/send`, {
99
+ method: "POST", headers,
100
+ body: JSON.stringify({ raw }),
92
101
  });
93
102
  if (!res.ok) return await safeApiErr(res, "Gmail");
94
103
  const data = (await res.json()) as any;
@@ -107,14 +116,16 @@ export default async function (input: Record<string, unknown>): Promise<string>
107
116
  const orig = await fetch(`${API}/messages/${message_id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Message-ID`, { headers });
108
117
  if (!orig.ok) return await safeApiErr(orig, "Gmail");
109
118
  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 }),
119
+ const getHeader = (name: string) => origData.payload?.headers?.find((h: any) => h.name === name)?.value ?? "";
120
+ const replyTo = getHeader("From");
121
+ const subj = getHeader("Subject").startsWith("Re:") ? getHeader("Subject") : `Re: ${getHeader("Subject")}`;
122
+ const msgId = getHeader("Message-ID");
123
+ const raw = Buffer.from(
124
+ `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}`
125
+ ).toString("base64url");
126
+ const res = await fetch(`${API}/messages/send`, {
127
+ method: "POST", headers,
128
+ body: JSON.stringify({ raw, threadId: origData.threadId }),
118
129
  });
119
130
  if (!res.ok) return await safeApiErr(res, "Gmail");
120
131
  const data = (await res.json()) as any;
@@ -53,8 +53,9 @@ const DEFAULT_PERMISSIONS: Record<string, ToolPermission> = {
53
53
  todos: "auto",
54
54
  notes: "auto",
55
55
  preferences: "auto",
56
- topics: "auto",
57
- follow_ups: "auto",
56
+ topics: "auto",
57
+ follow_ups: "auto",
58
+ email_send: "confirm",
58
59
 
59
60
  // Built-in skills — safe (read-only or low risk)
60
61
  web_search: "auto",
@@ -145,6 +146,7 @@ const TOOL_SCOPES: Record<string, ToolScope[]> = {
145
146
  preferences: ["memory"],
146
147
  topics: ["memory"],
147
148
  follow_ups: ["memory"],
149
+ email_send: ["network_write"],
148
150
  web_search: ["network_read"],
149
151
  url_fetch: ["network_read"],
150
152
  file_read: ["filesystem_read"],