Project Files
src / toolsProvider.ts
import { text, tool, type ToolCallContext, type ToolsProvider } from "@lmstudio/sdk";
import { z } from "zod";
import { pluginConfigSchematics } from "./config";
import { resolveAccount, getConfiguredAccounts } from "./accounts";
import { searchEmails, readEmail, listFolders, moveToTrash } from "./imap";
import { sendEmail } from "./smtp";
function json(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
function safe_impl<T extends Record<string, unknown>>(
name: string,
fn: (params: T, ctx: ToolCallContext) => Promise<string>
): (params: T, ctx: ToolCallContext) => Promise<string> {
return async (params: T, ctx: ToolCallContext) => {
if (ctx.signal.aborted) {
return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
}
try {
return await fn(params, ctx);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return JSON.stringify({ tool_error: true, tool: name, error: msg }, null, 2);
}
};
}
const ACCOUNT_PARAM = z.string().default("").describe(
'Account to use: slot number ("1", "2", "3"), label (e.g. "Work"), or blank for the first configured account.'
);
export const toolsProvider: ToolsProvider = async (ctl) => {
const cfg = ctl.getPluginConfig(pluginConfigSchematics);
return [
tool({
name: "email_accounts",
description: text`
List all configured email accounts with their slot number and label.
Call this first in any session to know which accounts are available and which
account parameter to pass to other tools ("1", "2", "Work", "Personal", etc.).
`,
parameters: {},
implementation: safe_impl("email_accounts", async (_params, ctx) => {
ctx.status("Reading account config…");
const accounts = getConfiguredAccounts(cfg);
if (accounts.length === 0) {
return json({ error: "No accounts configured. Add credentials in plugin settings." });
}
return json({
total: accounts.length,
accounts: accounts.map(a => ({
slot: a.slot,
label: a.label,
user: a.imap.user,
canSend: a.smtp !== null,
})),
});
}),
}),
tool({
name: "email_list",
description: text`
List recent emails from a mailbox folder.
Returns the N most recent emails with subject, from, date, and snippet.
account: slot number or label ("1", "Work", "Personal"). Blank = first account.
`,
parameters: {
account: ACCOUNT_PARAM,
folder: z.string().default("").describe("Folder name. Blank = plugin default (INBOX)."),
limit: z.coerce.number().int().min(1).max(200).default(0).describe("Emails per page (0 = plugin default)."),
offset: z.coerce.number().int().min(0).default(0).describe("Skip this many emails. Use to page through results: 0 = first page, 10 = second page (if limit=10), etc."),
unseen: z.boolean().default(false).describe("Return only unread emails."),
},
implementation: safe_impl("email_list", async ({ account, folder, limit, offset, unseen }, ctx) => {
const acc = resolveAccount(cfg, account);
const folderName = folder || cfg.get("defaultFolder");
const n = limit > 0 ? limit : cfg.get("maxResults");
ctx.status(`[${acc.label}] Listing ${folderName} (offset ${offset})…`);
const result = await searchEmails(acc.imap, folderName, {
unseen: unseen || undefined,
limit: n,
offset,
});
return json({
account: acc.label,
folder: folderName,
total: result.total,
offset: result.offset,
returned: result.emails.length,
hasMore: result.hasMore,
nextOffset: result.hasMore ? offset + n : null,
emails: result.emails.map(e => ({
uid: e.uid,
subject: e.subject,
from: e.from,
date: e.date,
snippet: e.snippet,
hasAttachments: e.hasAttachments,
})),
});
}),
}),
tool({
name: "email_search",
description: text`
Search emails by keywords, sender, subject, date range, or unread status.
Returns matching emails with subject, from, date, and snippet.
Use email_read(uid) to get the full body of a specific result.
account: slot number or label. Blank = first account.
Examples:
- email_search(query="invoice March") — full-text search
- email_search(from="sarah@co.com", account="Work") — sender filter on work account
- email_search(subject="contract", since="2024-01-01") — subject + date
- email_search(unseen=true, account="2") — unread in account 2
`,
parameters: {
account: ACCOUNT_PARAM,
query: z.string().default("").describe("Full-text search string."),
from: z.string().default("").describe("Filter by sender name or email."),
to: z.string().default("").describe("Filter by recipient email."),
subject: z.string().default("").describe("Filter by subject keywords."),
since: z.string().default("").describe("Emails after this date (YYYY-MM-DD or '2 weeks ago')."),
before: z.string().default("").describe("Emails before this date (YYYY-MM-DD)."),
folder: z.string().default("").describe("Mailbox folder. Blank = plugin default."),
unseen: z.boolean().default(false).describe("Only unread emails."),
limit: z.coerce.number().int().min(1).max(200).default(0).describe("Emails per page (0 = plugin default)."),
offset: z.coerce.number().int().min(0).default(0).describe("Skip this many results for pagination. 0 = first page."),
},
implementation: safe_impl("email_search", async ({ account, query, from, to, subject, since, before, folder, unseen, limit, offset }, ctx) => {
const acc = resolveAccount(cfg, account);
const folderName = folder || cfg.get("defaultFolder");
const n = limit > 0 ? limit : cfg.get("maxResults");
ctx.status(`[${acc.label}] Searching ${folderName} (offset ${offset})…`);
const result = await searchEmails(acc.imap, folderName, {
query: query || undefined,
from: from || undefined,
to: to || undefined,
subject: subject || undefined,
since: since ? new Date(since) : undefined,
before: before ? new Date(before) : undefined,
unseen: unseen || undefined,
limit: n,
offset,
});
return json({
account: acc.label,
folder: folderName,
total: result.total,
offset: result.offset,
returned: result.emails.length,
hasMore: result.hasMore,
nextOffset: result.hasMore ? offset + n : null,
emails: result.emails.map(e => ({
uid: e.uid,
subject: e.subject,
from: e.from,
to: e.to,
date: e.date,
snippet: e.snippet,
hasAttachments: e.hasAttachments,
})),
});
}),
}),
tool({
name: "email_read",
description: text`
Read the full content of a specific email by its UID.
Returns: subject, from, to, cc, date, full body text, and attachment list.
Get the UID from email_search or email_list first.
account: slot number or label matching the account where you found the email.
`,
parameters: {
uid: z.coerce.number().int().describe("Email UID from email_search or email_list."),
account: ACCOUNT_PARAM,
folder: z.string().default("").describe("Folder containing the email. Blank = plugin default."),
},
implementation: safe_impl("email_read", async ({ uid, account, folder }, ctx) => {
const acc = resolveAccount(cfg, account);
const folderName = folder || cfg.get("defaultFolder");
ctx.status(`[${acc.label}] Reading UID ${uid}…`);
const email = await readEmail(acc.imap, folderName, uid);
return json({ account: acc.label, ...email });
}),
}),
tool({
name: "email_list_folders",
description: text`
List all mailbox folders for an account (INBOX, Sent, Archive, custom folders).
Call once to discover folder names before searching in a specific folder.
account: slot number or label. Blank = first account.
`,
parameters: {
account: ACCOUNT_PARAM,
},
implementation: safe_impl("email_list_folders", async ({ account }, ctx) => {
const acc = resolveAccount(cfg, account);
ctx.status(`[${acc.label}] Fetching folder list…`);
const folders = await listFolders(acc.imap);
return json({ account: acc.label, total: folders.length, folders });
}),
}),
tool({
name: "email_delete",
description: text`
Move one or more emails to the Trash folder (recoverable).
Emails are NOT permanently deleted — they land in Trash and can be restored.
ALWAYS confirm with the user before calling — list what will be trashed.
Get UIDs from email_search or email_list first.
account: slot number or label. Blank = first account.
`,
parameters: {
uids: z.array(z.coerce.number().int()).min(1).describe("One or more email UIDs to move to Trash."),
account: ACCOUNT_PARAM,
folder: z.string().default("").describe("Source folder. Blank = plugin default (INBOX)."),
trash_folder: z.string().default("").describe("Override Trash folder name. Blank = auto-detect (recommended)."),
},
implementation: safe_impl("email_delete", async ({ uids, account, folder, trash_folder }, ctx) => {
const acc = resolveAccount(cfg, account);
const folderName = folder || cfg.get("defaultFolder");
ctx.status(`[${acc.label}] Moving ${uids.length} email(s) to Trash…`);
const result = await moveToTrash(acc.imap, folderName, uids, trash_folder);
return json({
success: true,
account: acc.label,
moved: result.moved,
from: folderName,
to: result.trashFolder,
uids,
});
}),
}),
tool({
name: "email_send",
description: text`
Send an email via SMTP. ALWAYS confirm with the user before calling — sending is irreversible.
To reply, first call email_read to get the messageId, then pass it as reply_to_message_id.
account: slot number or label for the sending account. Blank = first account.
SMTP must be configured for the chosen account (smtpHost in settings).
`,
parameters: {
account: ACCOUNT_PARAM,
to: z.string().describe("Recipient address(es), comma-separated."),
subject: z.string().describe("Email subject line."),
body: z.string().describe("Plain text body."),
cc: z.string().default("").describe("CC recipients, comma-separated."),
reply_to_message_id: z.string().default("").describe("MessageId of the email being replied to."),
},
implementation: safe_impl("email_send", async ({ account, to, subject, body, cc, reply_to_message_id }, ctx) => {
const acc = resolveAccount(cfg, account);
if (!acc.smtp) {
throw new Error(`Account "${acc.label}" has no SMTP host configured. Add smtp host in plugin settings to enable sending.`);
}
ctx.status(`[${acc.label}] Sending to ${to}…`);
const messageId = await sendEmail(acc.smtp, {
from: acc.imap.user,
to,
cc: cc || undefined,
subject,
body,
inReplyTo: reply_to_message_id || undefined,
references: reply_to_message_id || undefined,
});
return json({ success: true, account: acc.label, messageId, to, subject });
}),
}),
];
};