first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:04:09 +02:00
commit 3cb40adb23
203 changed files with 40226 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
# Productier OpenClaw Plugin
This package includes the OpenClaw baseline tools for Productier plus runtime hardening:
- transient retry handling (network + `429/5xx`)
- per-tool rate limiting
- structured JSONL audit logging
## Included tools
- `productier_list_workspaces`
- `productier_list_board_groups`
- `productier_list_tasks`
- `productier_list_calendar_events`
- `productier_list_notes`
- `productier_list_mailboxes`
- `productier_list_mail_messages`
- `productier_list_outgoing_mails`
- `productier_connect_mailbox`
- `productier_sync_mailbox`
- `productier_create_board_group`
- `productier_create_task`
- `productier_create_calendar_event`
- `productier_create_note`
- `productier_create_outgoing_mail`
- `productier_create_task_from_mail`
- `productier_update_board_group`
- `productier_update_task`
- `productier_update_calendar_event`
- `productier_update_note`
Profiles:
- `readonly`: `productier_list_workspaces`, `productier_list_tasks`
- `standard`: `readonly` tools + board/calendar/notes/task tools + mailbox management + outgoing mail + mail task conversion tools
## Environment
- `PRODUCTIER_API_URL` (default: `http://localhost:8080`)
- `PRODUCTIER_AUTH_COOKIE` (Better Auth session cookie string used by backend `/v1/*` endpoints)
- `PRODUCTIER_WORKSPACE_SLUG_DEFAULT` (optional fallback workspace slug)
- `PRODUCTIER_BOARD_GROUP_ID_DEFAULT` (optional fallback board group id for create-task tool)
- `PRODUCTIER_TOOL_PROFILE` (`readonly` or `standard`, default `readonly`)
- `PRODUCTIER_TOOL_RETRY_MAX_ATTEMPTS` (default: `3`)
- `PRODUCTIER_TOOL_RETRY_BASE_DELAY_MS` (default: `150`)
- `PRODUCTIER_TOOL_RETRY_MAX_DELAY_MS` (default: `2000`)
- `PRODUCTIER_TOOL_RETRY_JITTER_MS` (default: `50`)
- `PRODUCTIER_TOOL_RATE_LIMIT_MAX_CALLS` (default: `120`)
- `PRODUCTIER_TOOL_RATE_LIMIT_WINDOW_MS` (default: `60000`)
- `PRODUCTIER_AUDIT_LOG_PATH` (optional JSONL path for per-tool execution audit entries)
## Quick usage
Describe plugin and tool metadata:
```bash
npm run describe -w packages/openclaw-plugin
```
Call the tool:
```bash
node packages/openclaw-plugin/src/cli.mjs call productier_list_tasks '{"workspaceSlug":"personal","limit":20}'
```
Create a task using defaults:
```bash
PRODUCTIER_TOOL_PROFILE=standard \
PRODUCTIER_WORKSPACE_SLUG_DEFAULT=personal \
PRODUCTIER_BOARD_GROUP_ID_DEFAULT=group-inbox \
node packages/openclaw-plugin/src/cli.mjs call productier_create_task '{"title":"Write release notes"}'
```
The tool call returns:
- `ok: true` with `data` on success
- `ok: false` with structured `error` (`code`, `message`, optional `status`, `requestId`, `attempts`, `retryable`) on failure
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@productier/openclaw-plugin",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.mjs",
"exports": {
".": "./src/index.mjs"
},
"scripts": {
"check": "node --test",
"describe": "node ./src/cli.mjs describe"
}
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
import { createPlugin } from "./index.mjs";
function printJSON(value) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
async function main() {
const plugin = createPlugin();
const [, , command = "describe", toolName, rawInput] = process.argv;
if (command === "describe") {
const toolsByProfile = Object.fromEntries(
Object.keys(plugin.manifest.profiles).map(profileName => [
profileName,
createPlugin({ profile: profileName }).listTools()
]),
);
printJSON({
manifest: plugin.manifest,
profile: process.env.PRODUCTIER_TOOL_PROFILE || "readonly",
tools: plugin.listTools(),
toolsByProfile
});
return;
}
if (command === "call") {
if (!toolName) {
throw new Error("tool name is required: node src/cli.mjs call <toolName> '{\"workspaceSlug\":\"personal\"}'");
}
let input = {};
if (rawInput) {
try {
input = JSON.parse(rawInput);
} catch {
throw new Error("input must be valid JSON");
}
}
const result = await plugin.runTool(toolName, input);
printJSON(result);
process.exitCode = result.ok ? 0 : 1;
return;
}
throw new Error(`unknown command: ${command}`);
}
main().catch(error => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});
+152
View File
@@ -0,0 +1,152 @@
const DEFAULT_API_URL = "http://localhost:8080";
export class ProductierApiError extends Error {
constructor(message, status, code, requestId) {
super(message);
this.name = "ProductierApiError";
this.status = status;
this.code = code ?? "unknown_error";
this.requestId = requestId ?? null;
}
}
function readErrorMessage(payload, fallback) {
if (!payload || typeof payload !== "object") {
return fallback;
}
if (payload.error && typeof payload.error === "object" && typeof payload.error.message === "string") {
return payload.error.message;
}
if (typeof payload.message === "string") {
return payload.message;
}
return fallback;
}
export function createProductierClient(options = {}) {
const apiUrl = (options.apiUrl || process.env.PRODUCTIER_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
const authCookie = options.authCookie || process.env.PRODUCTIER_AUTH_COOKIE || "";
const fetchImpl = options.fetchImpl || globalThis.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("fetch implementation is required");
}
async function requestJSON(method, path, { query = {}, body } = {}) {
const url = new URL(`${apiUrl}${path}`);
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === "") {
continue;
}
url.searchParams.set(key, String(value));
}
const headers = {
...(authCookie ? { Cookie: authCookie } : {}),
Accept: "application/json"
};
const init = {
method,
headers
};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
const response = await fetchImpl(url, {
...init
});
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
const message = readErrorMessage(payload, `request failed with status ${response.status}`);
const code = payload?.error?.code;
const requestId = payload?.error?.requestId || response.headers.get("x-request-id");
throw new ProductierApiError(message, response.status, code, requestId);
}
return payload;
}
return {
async listWorkspaces() {
return requestJSON("GET", "/v1/workspaces");
},
async listBoardGroups(workspaceSlug) {
return requestJSON("GET", "/v1/board-groups", { query: { workspaceSlug } });
},
async createBoardGroup(input) {
return requestJSON("POST", "/v1/board-groups", { body: input });
},
async updateBoardGroup(groupId, input) {
return requestJSON("PATCH", `/v1/board-groups/${encodeURIComponent(groupId)}`, { body: input });
},
async listTasks(workspaceSlug) {
return requestJSON("GET", "/v1/tasks", { query: { workspaceSlug } });
},
async createTask(input) {
return requestJSON("POST", "/v1/tasks", { body: input });
},
async updateTask(taskId, input) {
return requestJSON("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}`, { body: input });
},
async listCalendarEvents(workspaceSlug) {
return requestJSON("GET", "/v1/calendar/events", { query: { workspaceSlug } });
},
async createCalendarEvent(input) {
return requestJSON("POST", "/v1/calendar/events", { body: input });
},
async updateCalendarEvent(eventId, input) {
return requestJSON("PATCH", `/v1/calendar/events/${encodeURIComponent(eventId)}`, { body: input });
},
async listNotes(workspaceSlug) {
return requestJSON("GET", "/v1/notes", { query: { workspaceSlug } });
},
async createNote(input) {
return requestJSON("POST", "/v1/notes", { body: input });
},
async updateNote(noteId, input) {
return requestJSON("PATCH", `/v1/notes/${encodeURIComponent(noteId)}`, { body: input });
},
async listMailMessages(workspaceSlug, mailboxId) {
return requestJSON("GET", "/v1/mail/messages", {
query: {
workspaceSlug,
mailboxId
}
});
},
async listMailboxes(workspaceSlug) {
return requestJSON("GET", "/v1/mailboxes", { query: { workspaceSlug } });
},
async connectMailbox(input) {
return requestJSON("POST", "/v1/mailboxes", { body: input });
},
async syncMailbox(mailboxId) {
return requestJSON("POST", `/v1/mailboxes/${encodeURIComponent(mailboxId)}/sync`);
},
async listOutgoingMails(workspaceSlug, mailboxId) {
return requestJSON("GET", "/v1/mail/outgoing", {
query: {
workspaceSlug,
mailboxId
}
});
},
async createOutgoingMail(input) {
return requestJSON("POST", "/v1/mail/outgoing", { body: input });
},
async createTaskFromMail(messageId, input) {
return requestJSON("POST", `/v1/mail/messages/${encodeURIComponent(messageId)}/create-task`, {
body: input
});
}
};
}
+233
View File
@@ -0,0 +1,233 @@
import { createProductierClient, ProductierApiError } from "./client.mjs";
import {
createAuditLogger,
createRateLimiter,
createRuntimeConfig,
invokeWithRetry,
ToolRateLimitError,
unwrapInvocationError
} from "./runtime-hardening.mjs";
import { createConnectMailboxTool } from "./tool-connect-mailbox.mjs";
import { createCreateBoardGroupTool } from "./tool-create-board-group.mjs";
import { createCreateCalendarEventTool } from "./tool-create-calendar-event.mjs";
import { createCreateOutgoingMailTool } from "./tool-create-outgoing-mail.mjs";
import { createCreateTaskFromMailTool } from "./tool-create-task-from-mail.mjs";
import { createCreateNoteTool } from "./tool-create-note.mjs";
import { createCreateTaskTool } from "./tool-create-task.mjs";
import { createListBoardGroupsTool } from "./tool-list-board-groups.mjs";
import { createListCalendarEventsTool } from "./tool-list-calendar-events.mjs";
import { createListMailboxesTool } from "./tool-list-mailboxes.mjs";
import { createListMailMessagesTool } from "./tool-list-mail-messages.mjs";
import { createListNotesTool } from "./tool-list-notes.mjs";
import { createListOutgoingMailsTool } from "./tool-list-outgoing-mails.mjs";
import { createListTasksTool } from "./tool-list-tasks.mjs";
import { createListWorkspacesTool } from "./tool-list-workspaces.mjs";
import { createSyncMailboxTool } from "./tool-sync-mailbox.mjs";
import { createUpdateBoardGroupTool } from "./tool-update-board-group.mjs";
import { createUpdateCalendarEventTool } from "./tool-update-calendar-event.mjs";
import { createUpdateNoteTool } from "./tool-update-note.mjs";
import { createUpdateTaskTool } from "./tool-update-task.mjs";
export const pluginManifest = {
name: "productier-openclaw",
version: "0.1.0",
description: "OpenClaw tool plugin for Productier workspace operations.",
profiles: {
readonly: {
tools: ["productier_list_workspaces", "productier_list_tasks"]
},
standard: {
tools: [
"productier_list_workspaces",
"productier_list_board_groups",
"productier_list_tasks",
"productier_list_calendar_events",
"productier_list_notes",
"productier_list_mailboxes",
"productier_list_mail_messages",
"productier_list_outgoing_mails",
"productier_connect_mailbox",
"productier_sync_mailbox",
"productier_create_board_group",
"productier_create_task",
"productier_create_calendar_event",
"productier_create_note",
"productier_create_outgoing_mail",
"productier_create_task_from_mail",
"productier_update_board_group",
"productier_update_task",
"productier_update_calendar_event",
"productier_update_note"
]
}
}
};
export function createPlugin(options = {}) {
const profile = options.profile || process.env.PRODUCTIER_TOOL_PROFILE || "readonly";
if (profile !== "readonly" && profile !== "standard") {
throw new Error(`unsupported profile: ${profile}`);
}
const runtimeConfig = createRuntimeConfig(options);
const rateLimiter = createRateLimiter(runtimeConfig.rateLimit, options);
const auditLogger = createAuditLogger(options, runtimeConfig);
const client = createProductierClient(options);
const listWorkspaces = createListWorkspacesTool(client);
const listBoardGroups = createListBoardGroupsTool(client, options);
const listTasks = createListTasksTool(client, options);
const listCalendarEvents = createListCalendarEventsTool(client, options);
const listNotes = createListNotesTool(client, options);
const listMailboxes = createListMailboxesTool(client, options);
const listMailMessages = createListMailMessagesTool(client, options);
const listOutgoingMails = createListOutgoingMailsTool(client, options);
const connectMailbox = createConnectMailboxTool(client, options);
const syncMailbox = createSyncMailboxTool(client);
const createBoardGroup = createCreateBoardGroupTool(client, options);
const createTask = createCreateTaskTool(client, options);
const createCalendarEvent = createCreateCalendarEventTool(client, options);
const createNote = createCreateNoteTool(client, options);
const createOutgoingMail = createCreateOutgoingMailTool(client, options);
const createTaskFromMail = createCreateTaskFromMailTool(client, options);
const updateBoardGroup = createUpdateBoardGroupTool(client);
const updateTask = createUpdateTaskTool(client);
const updateCalendarEvent = createUpdateCalendarEventTool(client);
const updateNote = createUpdateNoteTool(client);
const allTools = [
listWorkspaces,
listBoardGroups,
listTasks,
listCalendarEvents,
listNotes,
listMailboxes,
listMailMessages,
listOutgoingMails,
connectMailbox,
syncMailbox,
createBoardGroup,
createTask,
createCalendarEvent,
createNote,
createOutgoingMail,
createTaskFromMail,
updateBoardGroup,
updateTask,
updateCalendarEvent,
updateNote
];
const enabledToolNames = new Set(pluginManifest.profiles[profile].tools);
const tools = new Map(allTools.filter(tool => enabledToolNames.has(tool.name)).map(tool => [tool.name, tool]));
return {
manifest: pluginManifest,
listTools() {
return [...tools.values()].map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema
}));
},
async runTool(name, input) {
const tool = tools.get(name);
if (!tool) {
return {
ok: false,
error: {
code: "tool_not_found",
message: `Unknown tool: ${name}`
}
};
}
const startedAt = Date.now();
const recordedInput = input || {};
try {
rateLimiter.consume(name);
const { data, attempts } = await invokeWithRetry(
() => tool.invoke(recordedInput),
runtimeConfig.retry,
options,
);
const result = {
ok: true,
data,
meta: {
attempts,
durationMs: Date.now() - startedAt
}
};
await auditLogger.log({
timestamp: new Date().toISOString(),
profile,
tool: name,
ok: true,
attempts,
durationMs: result.meta.durationMs,
errorCode: null,
requestId: null
});
return result;
} catch (error) {
const normalizedError = unwrapInvocationError(error);
const cause = normalizedError.cause;
const durationMs = Date.now() - startedAt;
let result = null;
if (cause instanceof ToolRateLimitError) {
result = {
ok: false,
error: {
code: cause.code,
message: cause.message,
status: cause.status,
retryAfterMs: cause.retryAfterMs,
retryable: false,
attempts: normalizedError.attempts
}
};
} else if (cause instanceof ProductierApiError) {
result = {
ok: false,
error: {
code: cause.code,
message: cause.message,
status: cause.status,
requestId: cause.requestId,
retryable: normalizedError.retryable,
attempts: normalizedError.attempts
}
};
} else {
result = {
ok: false,
error: {
code: "tool_execution_error",
message: cause instanceof Error ? cause.message : "unknown error",
retryable: normalizedError.retryable,
attempts: normalizedError.attempts
}
};
}
await auditLogger.log({
timestamp: new Date().toISOString(),
profile,
tool: name,
ok: false,
attempts: normalizedError.attempts,
durationMs,
errorCode: result.error.code,
requestId: result.error.requestId ?? null,
retryable: result.error.retryable ?? false
});
return result;
}
}
};
}
@@ -0,0 +1,235 @@
import { appendFile } from "node:fs/promises";
import { ProductierApiError } from "./client.mjs";
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
const NETWORK_ERROR_PATTERN = /(network|fetch|socket|timeout|timed out|econnreset|enotfound|eai_again)/i;
function resolveNumberOption(value, envValue, fallback, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) {
const candidate = value ?? envValue;
if (candidate === undefined || candidate === null || candidate === "") {
return fallback;
}
const parsed = Number.parseInt(String(candidate), 10);
if (Number.isNaN(parsed)) {
return fallback;
}
if (parsed < min || parsed > max) {
return fallback;
}
return parsed;
}
function calculateRetryDelayMs(attempt, config, randomFn) {
const exponent = Math.max(0, attempt - 1);
const baseDelay = Math.min(config.maxDelayMs, config.baseDelayMs * (2 ** exponent));
const jitter = config.jitterMs > 0 ? Math.floor(randomFn() * (config.jitterMs + 1)) : 0;
return baseDelay + jitter;
}
export class ToolRateLimitError extends Error {
constructor(toolName, retryAfterMs) {
const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000));
super(`rate limit exceeded for ${toolName}; retry in ${retryAfterSeconds}s`);
this.name = "ToolRateLimitError";
this.status = 429;
this.code = "tool_rate_limited";
this.retryAfterMs = retryAfterMs;
}
}
export class ToolInvocationError extends Error {
constructor(cause, attempts, retryable) {
super(cause instanceof Error ? cause.message : "tool invocation failed");
this.name = "ToolInvocationError";
this.cause = cause;
this.attempts = attempts;
this.retryable = retryable;
}
}
export function isRetryableError(error) {
if (error instanceof ProductierApiError) {
return RETRYABLE_STATUSES.has(Number(error.status));
}
if (error instanceof ToolRateLimitError) {
return false;
}
if (!(error instanceof Error)) {
return false;
}
if (error.name === "AbortError" || error.name === "TimeoutError") {
return true;
}
if (error instanceof TypeError) {
return true;
}
return NETWORK_ERROR_PATTERN.test(error.message || "");
}
export function createRuntimeConfig(options = {}) {
return {
retry: {
maxAttempts: resolveNumberOption(
options.retryMaxAttempts ?? options.retry?.maxAttempts,
process.env.PRODUCTIER_TOOL_RETRY_MAX_ATTEMPTS,
3,
{ min: 1, max: 10 },
),
baseDelayMs: resolveNumberOption(
options.retryBaseDelayMs ?? options.retry?.baseDelayMs,
process.env.PRODUCTIER_TOOL_RETRY_BASE_DELAY_MS,
150,
{ min: 0, max: 60000 },
),
maxDelayMs: resolveNumberOption(
options.retryMaxDelayMs ?? options.retry?.maxDelayMs,
process.env.PRODUCTIER_TOOL_RETRY_MAX_DELAY_MS,
2000,
{ min: 0, max: 60000 },
),
jitterMs: resolveNumberOption(
options.retryJitterMs ?? options.retry?.jitterMs,
process.env.PRODUCTIER_TOOL_RETRY_JITTER_MS,
50,
{ min: 0, max: 10000 },
)
},
rateLimit: {
maxCalls: resolveNumberOption(
options.rateLimitMaxCalls ?? options.rateLimit?.maxCalls,
process.env.PRODUCTIER_TOOL_RATE_LIMIT_MAX_CALLS,
120,
{ min: 0, max: 100000 },
),
windowMs: resolveNumberOption(
options.rateLimitWindowMs ?? options.rateLimit?.windowMs,
process.env.PRODUCTIER_TOOL_RATE_LIMIT_WINDOW_MS,
60000,
{ min: 1, max: 3600000 },
)
},
auditLogPath: options.auditLogPath ?? process.env.PRODUCTIER_AUDIT_LOG_PATH ?? ""
};
}
export function createRateLimiter(config = {}, options = {}) {
const maxCalls = Number(config.maxCalls ?? 0);
const windowMs = Number(config.windowMs ?? 60000);
const nowFn = typeof options.nowFn === "function" ? options.nowFn : Date.now;
if (maxCalls < 1 || windowMs < 1) {
return {
consume() {
return;
}
};
}
const buckets = new Map();
return {
consume(toolName) {
const now = nowFn();
const windowStart = now - windowMs;
const existing = buckets.get(toolName) ?? [];
const withinWindow = existing.filter(timestamp => timestamp > windowStart);
if (withinWindow.length >= maxCalls) {
const oldestTimestamp = withinWindow[0];
const retryAfterMs = Math.max(1, windowMs - (now - oldestTimestamp));
throw new ToolRateLimitError(toolName, retryAfterMs);
}
withinWindow.push(now);
buckets.set(toolName, withinWindow);
}
};
}
export function createAuditLogger(options = {}, runtimeConfig = {}) {
const callback = options.auditLogFn;
if (typeof callback === "function") {
return {
async log(entry) {
try {
await callback(entry);
} catch {
// Never fail tool execution because audit logging failed.
}
}
};
}
const auditLogPath = runtimeConfig.auditLogPath;
if (!auditLogPath) {
return {
async log() {
return;
}
};
}
return {
async log(entry) {
try {
await appendFile(auditLogPath, `${JSON.stringify(entry)}\n`, "utf8");
} catch {
// Never fail tool execution because audit logging failed.
}
}
};
}
export async function invokeWithRetry(operation, config = {}, options = {}) {
const maxAttempts = Math.max(1, Number(config.maxAttempts ?? 1));
const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : ms => new Promise(resolve => setTimeout(resolve, ms));
const randomFn = typeof options.randomFn === "function" ? options.randomFn : Math.random;
let attempts = 0;
while (attempts < maxAttempts) {
attempts += 1;
try {
const data = await operation();
return { data, attempts };
} catch (cause) {
const retryable = isRetryableError(cause);
const shouldRetry = retryable && attempts < maxAttempts;
if (!shouldRetry) {
throw new ToolInvocationError(cause, attempts, retryable);
}
const delayMs = calculateRetryDelayMs(attempts, config, randomFn);
if (delayMs > 0) {
await sleepFn(delayMs);
}
}
}
throw new ToolInvocationError(new Error("tool invocation exhausted retry loop"), maxAttempts, false);
}
export function unwrapInvocationError(error) {
if (error instanceof ToolInvocationError) {
return {
cause: error.cause,
attempts: error.attempts,
retryable: error.retryable
};
}
return {
cause: error,
attempts: 1,
retryable: isRetryableError(error)
};
}
@@ -0,0 +1,115 @@
import { optionalTrimmedString, requireStringField, resolveWorkspaceSlug } from "./tool-helpers.mjs";
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function normalizePort(rawValue, fieldName, fallback) {
if (rawValue === undefined || rawValue === null || rawValue === "") {
return fallback;
}
const parsed = Number.parseInt(String(rawValue), 10);
if (Number.isNaN(parsed) || parsed < 1 || parsed > 65535) {
throw new Error(`${fieldName} must be an integer between 1 and 65535`);
}
return parsed;
}
function normalizeBoolean(rawValue, fieldName, fallback) {
if (rawValue === undefined || rawValue === null || rawValue === "") {
return fallback;
}
if (typeof rawValue === "boolean") {
return rawValue;
}
const value = String(rawValue).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(value)) {
return true;
}
if (["0", "false", "no", "off"].includes(value)) {
return false;
}
throw new Error(`${fieldName} must be a boolean`);
}
function validateEmail(email, fieldName) {
if (!EMAIL_PATTERN.test(email)) {
throw new Error(`${fieldName} must be a valid email address`);
}
}
export function createConnectMailboxTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_connect_mailbox",
description: "Connect an IMAP/SMTP mailbox to a Productier workspace.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
label: { type: "string", description: "Optional mailbox label." },
email: { type: "string", description: "Mailbox sender email address." },
displayName: { type: "string", description: "Optional sender display name." },
imapHost: { type: "string", description: "IMAP host (required)." },
imapPort: { type: "integer", minimum: 1, maximum: 65535, description: "IMAP port, default 993." },
imapUsername: { type: "string", description: "Optional IMAP username (defaults to email)." },
imapPassword: { type: "string", description: "IMAP password (required)." },
imapUseTls: { type: "boolean", description: "Use TLS for IMAP, default true." },
smtpHost: { type: "string", description: "SMTP host (required)." },
smtpPort: { type: "integer", minimum: 1, maximum: 65535, description: "SMTP port, default 587." },
smtpUsername: { type: "string", description: "Optional SMTP username (defaults to IMAP username)." },
smtpPassword: { type: "string", description: "Optional SMTP password (blank reuses IMAP password)." },
smtpUseTls: { type: "boolean", description: "Use TLS for SMTP, default true." }
},
required: ["email", "imapHost", "imapPassword", "smtpHost"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const email = requireStringField(rawInput.email, "email").toLowerCase();
validateEmail(email, "email");
const imapHost = requireStringField(rawInput.imapHost, "imapHost");
const imapPassword = requireStringField(rawInput.imapPassword, "imapPassword");
const smtpHost = requireStringField(rawInput.smtpHost, "smtpHost");
const label = optionalTrimmedString(rawInput.label);
const displayName = optionalTrimmedString(rawInput.displayName);
const imapUsername = optionalTrimmedString(rawInput.imapUsername);
const smtpUsername = optionalTrimmedString(rawInput.smtpUsername);
const smtpPassword = optionalTrimmedString(rawInput.smtpPassword);
const imapPort = normalizePort(rawInput.imapPort, "imapPort", 993);
const smtpPort = normalizePort(rawInput.smtpPort, "smtpPort", 587);
const imapUseTls = normalizeBoolean(rawInput.imapUseTls, "imapUseTls", true);
const smtpUseTls = normalizeBoolean(rawInput.smtpUseTls, "smtpUseTls", true);
const body = {
workspaceSlug,
email,
imapHost,
imapPort,
imapPassword,
imapUseTls,
smtpHost,
smtpPort,
smtpUseTls,
...(label ? { label } : {}),
...(displayName ? { displayName } : {}),
...(imapUsername ? { imapUsername } : {}),
...(smtpUsername ? { smtpUsername } : {}),
...(smtpPassword ? { smtpPassword } : {})
};
const response = await client.connectMailbox(body);
return {
connected: true,
workspaceSlug,
mailbox: response?.data ?? null
};
}
};
}
@@ -0,0 +1,40 @@
import { resolveWorkspaceSlug, requireStringField } from "./tool-helpers.mjs";
export function createCreateBoardGroupTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
const defaultColor = options.defaultBoardGroupColor || process.env.PRODUCTIER_BOARD_GROUP_COLOR_DEFAULT || "slate";
return {
name: "productier_create_board_group",
description: "Create a board group in a Productier workspace.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
name: { type: "string" },
color: { type: "string", description: "Color token. Defaults to PRODUCTIER_BOARD_GROUP_COLOR_DEFAULT or slate." }
},
required: ["name"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const name = requireStringField(rawInput.name, "name");
const color = rawInput.color ? String(rawInput.color) : defaultColor;
const response = await client.createBoardGroup({
workspaceSlug,
name,
color
});
return {
created: true,
boardGroup: response?.data ?? null
};
}
};
}
@@ -0,0 +1,56 @@
import { optionalISODateTime, optionalTrimmedString, resolveWorkspaceSlug, requireStringField } from "./tool-helpers.mjs";
export function createCreateCalendarEventTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_create_calendar_event",
description: "Create a Productier calendar event.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
title: { type: "string" },
description: { type: "string" },
startsAt: { type: "string", description: "ISO date-time." },
endsAt: { type: "string", description: "ISO date-time." },
linkedTaskId: { type: "string" },
color: { type: "string" }
},
required: ["title", "startsAt", "endsAt"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const title = requireStringField(rawInput.title, "title");
const startsAt = optionalISODateTime(requireStringField(rawInput.startsAt, "startsAt"), "startsAt");
const endsAt = optionalISODateTime(requireStringField(rawInput.endsAt, "endsAt"), "endsAt");
if (new Date(endsAt).getTime() < new Date(startsAt).getTime()) {
throw new Error("endsAt must be greater than or equal to startsAt");
}
const linkedTaskId = optionalTrimmedString(rawInput.linkedTaskId);
const color = optionalTrimmedString(rawInput.color);
const body = {
workspaceSlug,
title,
description: rawInput.description === undefined ? "" : String(rawInput.description),
startsAt,
endsAt,
...(linkedTaskId ? { linkedTaskId } : {}),
...(color ? { color } : {})
};
const response = await client.createCalendarEvent(body);
return {
created: true,
event: response?.data ?? null
};
}
};
}
@@ -0,0 +1,39 @@
import { resolveWorkspaceSlug, requireStringField } from "./tool-helpers.mjs";
export function createCreateNoteTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_create_note",
description: "Create a note in a Productier workspace.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
title: { type: "string" },
content: { type: "string" }
},
required: ["title", "content"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const title = requireStringField(rawInput.title, "title");
const content = requireStringField(rawInput.content, "content");
const response = await client.createNote({
workspaceSlug,
title,
content
});
return {
created: true,
note: response?.data ?? null
};
}
};
}
@@ -0,0 +1,156 @@
import {
optionalISODateTime,
optionalString,
optionalTrimmedString,
requireStringField,
resolveWorkspaceSlug
} from "./tool-helpers.mjs";
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function parseAddressString(rawAddress, fieldName, index) {
const value = String(rawAddress || "").trim();
if (!value) {
throw new Error(`${fieldName}[${index}] must be a non-empty email or "Name <email>"`);
}
const match = value.match(/^(.*?)<([^>]+)>$/);
const name = match ? match[1].trim() : "";
const email = (match ? match[2] : value).trim().toLowerCase();
if (!EMAIL_PATTERN.test(email)) {
throw new Error(`${fieldName}[${index}] must include a valid email address`);
}
return name ? { name, email } : { email };
}
function parseAddressObject(rawAddress, fieldName, index) {
if (!rawAddress || typeof rawAddress !== "object" || Array.isArray(rawAddress)) {
throw new Error(`${fieldName}[${index}] must be a string or object`);
}
const email = String(rawAddress.email || "").trim().toLowerCase();
if (!EMAIL_PATTERN.test(email)) {
throw new Error(`${fieldName}[${index}].email must be a valid email address`);
}
const name = optionalTrimmedString(rawAddress.name);
return name ? { name, email } : { email };
}
function normalizeAddressList(rawValue, fieldName, required) {
if (rawValue === undefined || rawValue === null || rawValue === "") {
if (required) {
throw new Error(`${fieldName} is required`);
}
return [];
}
let values = [];
if (typeof rawValue === "string") {
values = rawValue
.split(",")
.map(part => part.trim())
.filter(Boolean);
} else if (Array.isArray(rawValue)) {
values = rawValue;
} else {
throw new Error(`${fieldName} must be a comma-separated string or an array`);
}
if (required && values.length === 0) {
throw new Error(`${fieldName} is required`);
}
return values.map((item, index) => {
if (typeof item === "string") {
return parseAddressString(item, fieldName, index);
}
return parseAddressObject(item, fieldName, index);
});
}
export function createCreateOutgoingMailTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_create_outgoing_mail",
description: "Queue an outgoing email (send now or schedule) in Productier.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
mailboxId: { type: "string", description: "Mailbox id used for delivery." },
to: {
description: "Recipients as comma-separated string or array of strings/objects ({email,name}).",
anyOf: [
{ type: "string" },
{
type: "array",
items: {
anyOf: [
{ type: "string" },
{
type: "object",
properties: {
email: { type: "string" },
name: { type: "string" }
},
required: ["email"],
additionalProperties: false
}
]
}
}
]
},
cc: {
description: "Optional recipients as comma-separated string or array of strings/objects ({email,name}).",
anyOf: [{ type: "string" }, { type: "array" }]
},
bcc: {
description: "Optional recipients as comma-separated string or array of strings/objects ({email,name}).",
anyOf: [{ type: "string" }, { type: "array" }]
},
subject: { type: "string" },
textBody: { type: "string" },
htmlBody: { type: "string" },
scheduledFor: { type: "string", description: "Optional ISO date-time; future values schedule delivery." }
},
required: ["mailboxId", "to"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const mailboxId = requireStringField(rawInput.mailboxId, "mailboxId");
const to = normalizeAddressList(rawInput.to, "to", true);
const cc = normalizeAddressList(rawInput.cc, "cc", false);
const bcc = normalizeAddressList(rawInput.bcc, "bcc", false);
const subject = optionalString(rawInput.subject);
const textBody = optionalString(rawInput.textBody);
const htmlBody = optionalString(rawInput.htmlBody);
const scheduledFor = optionalISODateTime(rawInput.scheduledFor, "scheduledFor");
const body = {
workspaceSlug,
mailboxId,
to,
...(cc.length ? { cc } : {}),
...(bcc.length ? { bcc } : {}),
...(subject !== undefined ? { subject } : {}),
...(textBody !== undefined ? { textBody } : {}),
...(htmlBody !== undefined ? { htmlBody } : {}),
...(scheduledFor ? { scheduledFor } : {})
};
const response = await client.createOutgoingMail(body);
return {
queued: true,
workspaceSlug,
mailboxId,
outgoingMail: response?.data ?? null
};
}
};
}
@@ -0,0 +1,49 @@
import { optionalISODateTime, optionalTrimmedString, requireStringField, resolveWorkspaceSlug } from "./tool-helpers.mjs";
export function createCreateTaskFromMailTool(client, options = {}) {
const defaultBoardGroupId = options.defaultBoardGroupId || process.env.PRODUCTIER_BOARD_GROUP_ID_DEFAULT || "";
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_create_task_from_mail",
description: "Create a task from a mail message in Productier.",
inputSchema: {
type: "object",
properties: {
messageId: { type: "string" },
boardGroupId: { type: "string", description: "Board group id. Defaults to PRODUCTIER_BOARD_GROUP_ID_DEFAULT." },
title: { type: "string", description: "Optional custom task title." },
dueAt: { type: "string", description: "Optional ISO date-time." },
color: { type: "string", description: "Optional task color token." },
workspaceSlug: { type: "string", description: "Optional workspace slug for output context only." }
},
required: ["messageId"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const messageId = requireStringField(rawInput.messageId, "messageId");
const boardGroupId = String(rawInput.boardGroupId || defaultBoardGroupId).trim();
if (!boardGroupId) {
throw new Error("boardGroupId is required (or set PRODUCTIER_BOARD_GROUP_ID_DEFAULT)");
}
const dueAt = optionalISODateTime(rawInput.dueAt, "dueAt");
const title = optionalTrimmedString(rawInput.title);
const color = optionalTrimmedString(rawInput.color);
const body = {
boardGroupId,
...(title ? { title } : {}),
...(dueAt ? { dueAt } : {}),
...(color ? { color } : {})
};
const response = await client.createTaskFromMail(messageId, body);
return {
created: true,
sourceMessageId: messageId,
workspaceSlug: resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug) || null,
task: response?.data ?? null
};
}
};
}
@@ -0,0 +1,60 @@
import { optionalISODateTime, resolveWorkspaceSlug, requireStringField } from "./tool-helpers.mjs";
const allowedStatuses = new Set(["todo", "in_progress", "done"]);
export function createCreateTaskTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
const defaultBoardGroupID = options.defaultBoardGroupId || process.env.PRODUCTIER_BOARD_GROUP_ID_DEFAULT || "";
return {
name: "productier_create_task",
description: "Create a task in a Productier workspace.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
boardGroupId: { type: "string", description: "Board group id. Defaults to PRODUCTIER_BOARD_GROUP_ID_DEFAULT." },
title: { type: "string" },
description: { type: "string" },
color: { type: "string", description: "Task color token. Defaults to slate." },
dueAt: { type: "string", description: "Optional ISO date-time string." }
},
required: ["title"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
const boardGroupId = resolveWorkspaceSlug(rawInput.boardGroupId, defaultBoardGroupID);
const title = requireStringField(rawInput.title, "title");
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
if (!boardGroupId) {
throw new Error("boardGroupId is required (or set PRODUCTIER_BOARD_GROUP_ID_DEFAULT)");
}
const dueAt = optionalISODateTime(rawInput.dueAt, "dueAt");
const body = {
workspaceSlug,
boardGroupId,
title,
description: rawInput.description ? String(rawInput.description) : "",
color: rawInput.color ? String(rawInput.color) : "slate",
...(dueAt ? { dueAt } : {})
};
const response = await client.createTask(body);
const task = response?.data ?? null;
if (task?.status && !allowedStatuses.has(String(task.status))) {
throw new Error(`unexpected task status from API: ${task.status}`);
}
return {
created: task !== null,
task
};
}
};
}
@@ -0,0 +1,61 @@
export function resolveWorkspaceSlug(rawValue, defaultWorkspaceSlug) {
return String(rawValue || defaultWorkspaceSlug || "").trim();
}
export function requireStringField(rawValue, fieldName) {
const value = String(rawValue || "").trim();
if (!value) {
throw new Error(`${fieldName} is required`);
}
return value;
}
export function optionalString(rawValue) {
if (rawValue === undefined) {
return undefined;
}
return String(rawValue);
}
export function optionalTrimmedString(rawValue) {
if (rawValue === undefined) {
return undefined;
}
const value = String(rawValue).trim();
if (!value) {
return undefined;
}
return value;
}
export function optionalISODateTime(rawValue, fieldName) {
if (rawValue === undefined) {
return undefined;
}
const value = String(rawValue).trim();
if (!value) {
return undefined;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new Error(`${fieldName} must be a valid ISO date-time string`);
}
return value;
}
export function ensureAtLeastOneField(body, message = "at least one field must be provided") {
if (Object.keys(body).length === 0) {
throw new Error(message);
}
}
export function normalizeLimit(rawValue, defaultValue, maxValue) {
if (rawValue === undefined || rawValue === null || rawValue === "") {
return defaultValue;
}
const parsed = Number.parseInt(String(rawValue), 10);
if (Number.isNaN(parsed) || parsed < 1 || parsed > maxValue) {
throw new Error(`limit must be an integer between 1 and ${maxValue}`);
}
return parsed;
}
@@ -0,0 +1,46 @@
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 200;
export function createListBoardGroupsTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_list_board_groups",
description: "List board groups for a Productier workspace.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
query: { type: "string", description: "Optional case-insensitive name filter." },
color: { type: "string", description: "Optional color filter." },
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max groups returned, default ${DEFAULT_LIMIT}.` }
},
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
const color = rawInput.color ? String(rawInput.color).trim().toLowerCase() : "";
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
const response = await client.listBoardGroups(workspaceSlug);
const groups = Array.isArray(response?.data) ? response.data : [];
const filtered = groups
.filter(group => (query ? String(group.name || "").toLowerCase().includes(query) : true))
.filter(group => (color ? String(group.color || "").toLowerCase() === color : true))
.slice(0, limit);
return {
workspaceSlug,
count: filtered.length,
truncated: groups.length > filtered.length,
boardGroups: filtered
};
}
};
}
@@ -0,0 +1,73 @@
import { normalizeLimit, optionalISODateTime, resolveWorkspaceSlug } from "./tool-helpers.mjs";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 200;
export function createListCalendarEventsTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_list_calendar_events",
description: "List calendar events for a Productier workspace.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
query: { type: "string", description: "Optional case-insensitive title/description filter." },
from: { type: "string", description: "Optional ISO date-time lower bound for startsAt." },
to: { type: "string", description: "Optional ISO date-time upper bound for startsAt." },
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max events returned, default ${DEFAULT_LIMIT}.` }
},
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
const from = optionalISODateTime(rawInput.from, "from");
const to = optionalISODateTime(rawInput.to, "to");
const fromMs = from ? new Date(from).getTime() : undefined;
const toMs = to ? new Date(to).getTime() : undefined;
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
const response = await client.listCalendarEvents(workspaceSlug);
const events = Array.isArray(response?.data) ? response.data : [];
const filtered = events
.filter(event => {
if (!query) {
return true;
}
const title = String(event.title || "").toLowerCase();
const description = String(event.description || "").toLowerCase();
return title.includes(query) || description.includes(query);
})
.filter(event => {
if (!fromMs && !toMs) {
return true;
}
const startsMs = new Date(String(event.startsAt || "")).getTime();
if (Number.isNaN(startsMs)) {
return false;
}
if (fromMs && startsMs < fromMs) {
return false;
}
if (toMs && startsMs > toMs) {
return false;
}
return true;
})
.slice(0, limit);
return {
workspaceSlug,
count: filtered.length,
truncated: events.length > filtered.length,
events: filtered
};
}
};
}
@@ -0,0 +1,83 @@
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 200;
function messageMatchesQuery(message, query) {
if (!query) {
return true;
}
const subject = String(message.subject || "").toLowerCase();
const snippet = String(message.snippet || "").toLowerCase();
const textBody = String(message.textBody || "").toLowerCase();
const fromName = String(message.from?.name || "").toLowerCase();
const fromEmail = String(message.from?.email || "").toLowerCase();
return (
subject.includes(query) ||
snippet.includes(query) ||
textBody.includes(query) ||
fromName.includes(query) ||
fromEmail.includes(query)
);
}
export function createListMailMessagesTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_list_mail_messages",
description: "List mail messages for a Productier workspace and optional mailbox.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
mailboxId: { type: "string", description: "Optional mailbox filter." },
query: { type: "string", description: "Optional case-insensitive search over sender/subject/snippet/body." },
unreadOnly: { type: "boolean", description: "When true, return only unread messages." },
linked: { type: "string", enum: ["all", "linked", "unlinked"], description: "Task-link filter, default all." },
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max messages returned, default ${DEFAULT_LIMIT}.` }
},
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const mailboxId = rawInput.mailboxId ? String(rawInput.mailboxId).trim() : undefined;
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
const unreadOnly = rawInput.unreadOnly === true;
const linked = rawInput.linked ? String(rawInput.linked).trim() : "all";
if (linked !== "all" && linked !== "linked" && linked !== "unlinked") {
throw new Error("linked must be one of: all, linked, unlinked");
}
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
const response = await client.listMailMessages(workspaceSlug, mailboxId);
const messages = Array.isArray(response?.data) ? response.data : [];
const filtered = messages
.filter(message => (unreadOnly ? message.isRead === false : true))
.filter(message => {
if (linked === "all") {
return true;
}
if (linked === "linked") {
return Boolean(message.linkedTaskId);
}
return !message.linkedTaskId;
})
.filter(message => messageMatchesQuery(message, query))
.slice(0, limit);
return {
workspaceSlug,
mailboxId: mailboxId || null,
count: filtered.length,
truncated: messages.length > filtered.length,
messages: filtered
};
}
};
}
@@ -0,0 +1,75 @@
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 200;
function mailboxMatchesQuery(mailbox, query) {
if (!query) {
return true;
}
const label = String(mailbox.label || "").toLowerCase();
const email = String(mailbox.email || "").toLowerCase();
const displayName = String(mailbox.displayName || "").toLowerCase();
const imapHost = String(mailbox.imapHost || "").toLowerCase();
const smtpHost = String(mailbox.smtpHost || "").toLowerCase();
const syncError = String(mailbox.syncError || "").toLowerCase();
return (
label.includes(query) ||
email.includes(query) ||
displayName.includes(query) ||
imapHost.includes(query) ||
smtpHost.includes(query) ||
syncError.includes(query)
);
}
export function createListMailboxesTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_list_mailboxes",
description: "List connected mailboxes for a Productier workspace.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
query: { type: "string", description: "Optional case-insensitive search over mailbox fields." },
syncStatus: {
type: "string",
enum: ["all", "idle", "syncing", "ready", "error"],
description: "Sync status filter, default all."
},
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max mailboxes returned, default ${DEFAULT_LIMIT}.` }
},
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
const syncStatus = rawInput.syncStatus ? String(rawInput.syncStatus).trim().toLowerCase() : "all";
if (!["all", "idle", "syncing", "ready", "error"].includes(syncStatus)) {
throw new Error("syncStatus must be one of: all, idle, syncing, ready, error");
}
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
const response = await client.listMailboxes(workspaceSlug);
const mailboxes = Array.isArray(response?.data) ? response.data : [];
const filtered = mailboxes
.filter(mailbox => (syncStatus === "all" ? true : String(mailbox.syncStatus) === syncStatus))
.filter(mailbox => mailboxMatchesQuery(mailbox, query))
.slice(0, limit);
return {
workspaceSlug,
count: filtered.length,
truncated: mailboxes.length > filtered.length,
mailboxes: filtered
};
}
};
}
@@ -0,0 +1,50 @@
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 200;
export function createListNotesTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_list_notes",
description: "List notes for a Productier workspace.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
query: { type: "string", description: "Optional case-insensitive title/content filter." },
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max notes returned, default ${DEFAULT_LIMIT}.` }
},
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
const response = await client.listNotes(workspaceSlug);
const notes = Array.isArray(response?.data) ? response.data : [];
const filtered = notes
.filter(note => {
if (!query) {
return true;
}
const title = String(note.title || "").toLowerCase();
const content = String(note.content || "").toLowerCase();
return title.includes(query) || content.includes(query);
})
.slice(0, limit);
return {
workspaceSlug,
count: filtered.length,
truncated: notes.length > filtered.length,
notes: filtered
};
}
};
}
@@ -0,0 +1,78 @@
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 200;
function outgoingMatchesQuery(item, query) {
if (!query) {
return true;
}
const subject = String(item.subject || "").toLowerCase();
const textBody = String(item.textBody || "").toLowerCase();
const htmlBody = String(item.htmlBody || "").toLowerCase();
const recipients = [...(item.to || []), ...(item.cc || []), ...(item.bcc || [])]
.flatMap(address => [address?.name, address?.email])
.filter(Boolean)
.map(value => String(value).toLowerCase());
return (
subject.includes(query) ||
textBody.includes(query) ||
htmlBody.includes(query) ||
recipients.some(value => value.includes(query))
);
}
export function createListOutgoingMailsTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_list_outgoing_mails",
description: "List outgoing mail queue items for a Productier workspace and optional mailbox.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
mailboxId: { type: "string", description: "Optional mailbox filter." },
status: {
type: "string",
enum: ["all", "queued", "scheduled", "sent", "failed"],
description: "Outgoing status filter, default all."
},
query: { type: "string", description: "Optional case-insensitive search over recipients and content." },
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max outgoing records returned, default ${DEFAULT_LIMIT}.` }
},
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const mailboxId = rawInput.mailboxId ? String(rawInput.mailboxId).trim() : undefined;
const status = rawInput.status ? String(rawInput.status).trim().toLowerCase() : "all";
if (!["all", "queued", "scheduled", "sent", "failed"].includes(status)) {
throw new Error("status must be one of: all, queued, scheduled, sent, failed");
}
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
const response = await client.listOutgoingMails(workspaceSlug, mailboxId);
const outgoing = Array.isArray(response?.data) ? response.data : [];
const filtered = outgoing
.filter(item => (status === "all" ? true : String(item.status) === status))
.filter(item => outgoingMatchesQuery(item, query))
.slice(0, limit);
return {
workspaceSlug,
mailboxId: mailboxId || null,
status,
count: filtered.length,
truncated: outgoing.length > filtered.length,
outgoing: filtered
};
}
};
}
@@ -0,0 +1,56 @@
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
const MAX_LIMIT = 200;
const DEFAULT_LIMIT = 50;
export function createListTasksTool(client, options = {}) {
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
return {
name: "productier_list_tasks",
description: "List tasks from a Productier workspace with optional status/text filtering.",
inputSchema: {
type: "object",
properties: {
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
status: { type: "string", description: "Optional status filter (for example todo, in_progress, done)." },
query: { type: "string", description: "Optional case-insensitive text filter over title and description." },
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max tasks returned, default ${DEFAULT_LIMIT}.` }
},
additionalProperties: false
},
async invoke(rawInput = {}) {
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
if (!workspaceSlug) {
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
}
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
const statusFilter = rawInput.status ? String(rawInput.status).trim().toLowerCase() : "";
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
const response = await client.listTasks(workspaceSlug);
const tasks = Array.isArray(response?.data) ? response.data : [];
const matched = tasks
.filter(task => (statusFilter ? String(task.status || "").toLowerCase() === statusFilter : true))
.filter(task => {
if (!query) {
return true;
}
const title = String(task.title || "").toLowerCase();
const description = String(task.description || "").toLowerCase();
return title.includes(query) || description.includes(query);
});
const filtered = matched.slice(0, limit);
return {
workspaceSlug,
count: filtered.length,
truncated: matched.length > limit,
tasks: filtered
};
}
};
}
@@ -0,0 +1,19 @@
export function createListWorkspacesTool(client) {
return {
name: "productier_list_workspaces",
description: "List workspaces visible to the authenticated Productier user.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false
},
async invoke() {
const response = await client.listWorkspaces();
const workspaces = Array.isArray(response?.data) ? response.data : [];
return {
count: workspaces.length,
workspaces
};
}
};
}
@@ -0,0 +1,25 @@
import { requireStringField } from "./tool-helpers.mjs";
export function createSyncMailboxTool(client) {
return {
name: "productier_sync_mailbox",
description: "Trigger mailbox sync and return the updated mailbox state.",
inputSchema: {
type: "object",
properties: {
mailboxId: { type: "string", description: "Mailbox id to sync." }
},
required: ["mailboxId"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const mailboxId = requireStringField(rawInput.mailboxId, "mailboxId");
const response = await client.syncMailbox(mailboxId);
return {
synced: true,
mailboxId,
mailbox: response?.data ?? null
};
}
};
}
@@ -0,0 +1,47 @@
import { ensureAtLeastOneField, requireStringField } from "./tool-helpers.mjs";
function optionalInteger(rawValue, fieldName) {
if (rawValue === undefined) {
return undefined;
}
const parsed = Number.parseInt(String(rawValue), 10);
if (Number.isNaN(parsed)) {
throw new Error(`${fieldName} must be an integer`);
}
return parsed;
}
export function createUpdateBoardGroupTool(client) {
return {
name: "productier_update_board_group",
description: "Update a Productier board group.",
inputSchema: {
type: "object",
properties: {
groupId: { type: "string" },
name: { type: "string" },
color: { type: "string" },
order: { type: "integer" }
},
required: ["groupId"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const groupId = requireStringField(rawInput.groupId, "groupId");
const body = Object.fromEntries(
Object.entries({
name: rawInput.name === undefined ? undefined : String(rawInput.name),
color: rawInput.color === undefined ? undefined : String(rawInput.color),
order: optionalInteger(rawInput.order, "order")
}).filter(([, value]) => value !== undefined),
);
ensureAtLeastOneField(body, "at least one field must be provided to update");
const response = await client.updateBoardGroup(groupId, body);
return {
updated: true,
boardGroup: response?.data ?? null
};
}
};
}
@@ -0,0 +1,49 @@
import { ensureAtLeastOneField, optionalISODateTime, optionalString, optionalTrimmedString, requireStringField } from "./tool-helpers.mjs";
export function createUpdateCalendarEventTool(client) {
return {
name: "productier_update_calendar_event",
description: "Update a Productier calendar event.",
inputSchema: {
type: "object",
properties: {
eventId: { type: "string" },
title: { type: "string" },
description: { type: "string" },
startsAt: { type: "string", description: "Optional ISO date-time." },
endsAt: { type: "string", description: "Optional ISO date-time." },
linkedTaskId: { type: "string" },
color: { type: "string" }
},
required: ["eventId"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const eventId = requireStringField(rawInput.eventId, "eventId");
const startsAt = optionalISODateTime(rawInput.startsAt, "startsAt");
const endsAt = optionalISODateTime(rawInput.endsAt, "endsAt");
if (startsAt && endsAt && new Date(endsAt).getTime() < new Date(startsAt).getTime()) {
throw new Error("endsAt must be greater than or equal to startsAt");
}
const body = Object.fromEntries(
Object.entries({
title: optionalString(rawInput.title),
description: optionalString(rawInput.description),
startsAt,
endsAt,
linkedTaskId: optionalTrimmedString(rawInput.linkedTaskId),
color: optionalTrimmedString(rawInput.color)
}).filter(([, value]) => value !== undefined),
);
ensureAtLeastOneField(body, "at least one field must be provided to update");
const response = await client.updateCalendarEvent(eventId, body);
return {
updated: true,
event: response?.data ?? null
};
}
};
}
@@ -0,0 +1,34 @@
import { ensureAtLeastOneField, optionalString, requireStringField } from "./tool-helpers.mjs";
export function createUpdateNoteTool(client) {
return {
name: "productier_update_note",
description: "Update a Productier note.",
inputSchema: {
type: "object",
properties: {
noteId: { type: "string" },
title: { type: "string" },
content: { type: "string" }
},
required: ["noteId"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const noteId = requireStringField(rawInput.noteId, "noteId");
const body = Object.fromEntries(
Object.entries({
title: optionalString(rawInput.title),
content: optionalString(rawInput.content)
}).filter(([, value]) => value !== undefined),
);
ensureAtLeastOneField(body, "at least one field must be provided to update");
const response = await client.updateNote(noteId, body);
return {
updated: true,
note: response?.data ?? null
};
}
};
}
@@ -0,0 +1,56 @@
import { ensureAtLeastOneField, optionalISODateTime, optionalString, requireStringField } from "./tool-helpers.mjs";
const allowedStatuses = new Set(["todo", "in_progress", "done"]);
export function createUpdateTaskTool(client) {
return {
name: "productier_update_task",
description: "Update a Productier task.",
inputSchema: {
type: "object",
properties: {
taskId: { type: "string" },
title: { type: "string" },
description: { type: "string" },
status: { type: "string", enum: ["todo", "in_progress", "done"] },
boardGroupId: { type: "string" },
color: { type: "string" },
dueAt: { type: "string", description: "Optional ISO date-time string." },
scheduledStart: { type: "string", description: "Optional ISO date-time string." },
scheduledEnd: { type: "string", description: "Optional ISO date-time string." },
assigneeId: { type: "string" }
},
required: ["taskId"],
additionalProperties: false
},
async invoke(rawInput = {}) {
const taskId = requireStringField(rawInput.taskId, "taskId");
const status = rawInput.status ? String(rawInput.status).trim() : undefined;
if (status && !allowedStatuses.has(status)) {
throw new Error("status must be one of: todo, in_progress, done");
}
const update = {
title: optionalString(rawInput.title),
description: optionalString(rawInput.description),
status,
boardGroupId: optionalString(rawInput.boardGroupId),
color: optionalString(rawInput.color),
dueAt: optionalISODateTime(rawInput.dueAt, "dueAt"),
scheduledStart: optionalISODateTime(rawInput.scheduledStart, "scheduledStart"),
scheduledEnd: optionalISODateTime(rawInput.scheduledEnd, "scheduledEnd"),
assigneeId: optionalString(rawInput.assigneeId)
};
const body = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined));
ensureAtLeastOneField(body, "at least one field must be provided to update");
const response = await client.updateTask(taskId, body);
return {
updated: true,
task: response?.data ?? null
};
}
};
}
@@ -0,0 +1,879 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createPlugin } from "../src/index.mjs";
function createJSONResponse(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: {
"Content-Type": "application/json"
}
});
}
test("lists readonly tool metadata", () => {
const plugin = createPlugin({
fetchImpl: async () => createJSONResponse({ data: [] })
});
const tools = plugin.listTools();
assert.equal(tools.length, 2);
assert.deepEqual(
tools.map(tool => tool.name),
["productier_list_workspaces", "productier_list_tasks"],
);
});
test("lists standard profile tool metadata", () => {
const plugin = createPlugin({
profile: "standard",
fetchImpl: async () => createJSONResponse({ data: [] })
});
assert.deepEqual(
plugin.listTools().map(tool => tool.name),
[
"productier_list_workspaces",
"productier_list_board_groups",
"productier_list_tasks",
"productier_list_calendar_events",
"productier_list_notes",
"productier_list_mailboxes",
"productier_list_mail_messages",
"productier_list_outgoing_mails",
"productier_connect_mailbox",
"productier_sync_mailbox",
"productier_create_board_group",
"productier_create_task",
"productier_create_calendar_event",
"productier_create_note",
"productier_create_outgoing_mail",
"productier_create_task_from_mail",
"productier_update_board_group",
"productier_update_task",
"productier_update_calendar_event",
"productier_update_note"
],
);
});
test("returns tool_not_found for unknown tool", async () => {
const plugin = createPlugin({
fetchImpl: async () => createJSONResponse({ data: [] })
});
const result = await plugin.runTool("unknown_tool", {});
assert.equal(result.ok, false);
assert.equal(result.error.code, "tool_not_found");
});
test("runs productier_list_workspaces", async () => {
const plugin = createPlugin({
fetchImpl: async () =>
createJSONResponse({
data: [{ id: "ws-1", slug: "personal", name: "Personal HQ", role: "owner", createdAt: "2026-01-01T00:00:00Z" }]
})
});
const result = await plugin.runTool("productier_list_workspaces", {});
assert.equal(result.ok, true);
assert.equal(result.data.count, 1);
assert.equal(result.data.workspaces[0].slug, "personal");
});
test("runs productier_list_board_groups with query/color filter", async () => {
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async () =>
createJSONResponse({
data: [
{ id: "group-1", name: "Inbox", color: "slate", order: 0 },
{ id: "group-2", name: "In progress", color: "sky", order: 1 },
{ id: "group-3", name: "Done", color: "slate", order: 2 }
]
})
});
const result = await plugin.runTool("productier_list_board_groups", {
query: "in",
color: "slate",
limit: 1
});
assert.equal(result.ok, true);
assert.equal(result.data.workspaceSlug, "personal");
assert.equal(result.data.count, 1);
assert.equal(result.data.truncated, true);
assert.equal(result.data.boardGroups[0].id, "group-1");
});
test("runs productier_list_tasks with local filtering", async () => {
const plugin = createPlugin({
fetchImpl: async () =>
createJSONResponse({
data: [
{ id: "task-1", title: "Ship docs", description: "Write release notes", status: "todo" },
{ id: "task-2", title: "Review API", description: "Read endpoints", status: "in_progress" },
{ id: "task-3", title: "Docs QA", description: "Review docs", status: "todo" }
]
}),
defaultWorkspaceSlug: "personal"
});
const result = await plugin.runTool("productier_list_tasks", {
status: "todo",
query: "docs",
limit: 1
});
assert.equal(result.ok, true);
assert.equal(result.data.workspaceSlug, "personal");
assert.equal(result.data.count, 1);
assert.equal(result.data.truncated, true);
assert.equal(result.data.tasks[0].id, "task-1");
});
test("runs productier_list_calendar_events with date/query filters", async () => {
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async () =>
createJSONResponse({
data: [
{ id: "ev-1", title: "Planning", description: "Q2 goals", startsAt: "2026-04-01T10:00:00Z" },
{ id: "ev-2", title: "Review", description: "Retro", startsAt: "2026-04-10T10:00:00Z" },
{ id: "ev-3", title: "Planning follow-up", description: "", startsAt: "2026-05-01T10:00:00Z" }
]
})
});
const result = await plugin.runTool("productier_list_calendar_events", {
query: "planning",
from: "2026-04-01T00:00:00Z",
to: "2026-04-30T23:59:59Z"
});
assert.equal(result.ok, true);
assert.equal(result.data.count, 1);
assert.equal(result.data.events[0].id, "ev-1");
});
test("runs productier_list_notes with query filter", async () => {
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async () =>
createJSONResponse({
data: [
{ id: "note-1", title: "Sprint plan", content: "Ship board polish" },
{ id: "note-2", title: "Random", content: "groceries" }
]
})
});
const result = await plugin.runTool("productier_list_notes", {
query: "sprint"
});
assert.equal(result.ok, true);
assert.equal(result.data.count, 1);
assert.equal(result.data.notes[0].id, "note-1");
});
test("runs productier_list_mail_messages with filters", async () => {
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async () =>
createJSONResponse({
data: [
{
id: "mail-1",
subject: "Sprint planning",
snippet: "please review",
textBody: "board updates",
from: { name: "Alex", email: "alex@example.com" },
isRead: false,
linkedTaskId: undefined
},
{
id: "mail-2",
subject: "Random",
snippet: "hello",
textBody: "",
from: { name: "Jamie", email: "jamie@example.com" },
isRead: true,
linkedTaskId: "task-9"
}
]
})
});
const result = await plugin.runTool("productier_list_mail_messages", {
unreadOnly: true,
linked: "unlinked",
query: "planning"
});
assert.equal(result.ok, true);
assert.equal(result.data.count, 1);
assert.equal(result.data.messages[0].id, "mail-1");
});
test("runs productier_list_mailboxes with filters", async () => {
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async () =>
createJSONResponse({
data: [
{ id: "mb-1", label: "Team", email: "team@example.com", syncStatus: "ready", syncError: "" },
{ id: "mb-2", label: "Alerts", email: "alerts@example.com", syncStatus: "error", syncError: "auth failed" }
]
})
});
const result = await plugin.runTool("productier_list_mailboxes", {
query: "auth",
syncStatus: "error"
});
assert.equal(result.ok, true);
assert.equal(result.data.count, 1);
assert.equal(result.data.mailboxes[0].id, "mb-2");
});
test("runs productier_list_outgoing_mails with filters", async () => {
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async () =>
createJSONResponse({
data: [
{
id: "out-1",
mailboxId: "mb-1",
status: "queued",
subject: "Release draft",
textBody: "please review",
htmlBody: "",
to: [{ email: "alex@example.com" }],
cc: [],
bcc: []
},
{
id: "out-2",
mailboxId: "mb-1",
status: "sent",
subject: "Invoice",
textBody: "",
htmlBody: "",
to: [{ email: "billing@example.com" }],
cc: [],
bcc: []
}
]
})
});
const result = await plugin.runTool("productier_list_outgoing_mails", {
status: "queued",
query: "release"
});
assert.equal(result.ok, true);
assert.equal(result.data.count, 1);
assert.equal(result.data.outgoing[0].id, "out-1");
});
test("runs productier_create_board_group", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: {
id: "group-new",
workspaceSlug: "personal",
name: "Blocked",
color: "slate",
order: 3
}
}, 201);
}
});
const result = await plugin.runTool("productier_create_board_group", {
name: "Blocked"
});
assert.equal(result.ok, true);
assert.equal(result.data.boardGroup.id, "group-new");
assert.equal(calls[0].init.method, "POST");
assert.match(calls[0].url, /\/v1\/board-groups$/);
});
test("runs productier_create_task and forwards payload", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
defaultBoardGroupId: "group-inbox",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: {
id: "task-10",
workspaceSlug: "personal",
boardGroupId: "group-inbox",
title: "Write docs",
description: "",
status: "todo",
color: "slate",
labelIds: [],
attachments: [],
comments: [],
createdAt: "2026-03-01T08:00:00Z",
updatedAt: "2026-03-01T08:00:00Z"
}
}, 201);
}
});
const result = await plugin.runTool("productier_create_task", {
title: "Write docs"
});
assert.equal(result.ok, true);
assert.equal(result.data.task.id, "task-10");
assert.equal(calls.length, 1);
assert.equal(calls[0].init.method, "POST");
assert.match(calls[0].url, /\/v1\/tasks$/);
assert.deepEqual(JSON.parse(calls[0].init.body), {
workspaceSlug: "personal",
boardGroupId: "group-inbox",
title: "Write docs",
description: "",
color: "slate"
});
});
test("runs productier_create_calendar_event and validates date ordering", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: {
id: "ev-new",
workspaceSlug: "personal",
title: "Sync",
startsAt: "2026-04-03T10:00:00Z",
endsAt: "2026-04-03T11:00:00Z"
}
}, 201);
}
});
const result = await plugin.runTool("productier_create_calendar_event", {
title: "Sync",
startsAt: "2026-04-03T10:00:00Z",
endsAt: "2026-04-03T11:00:00Z"
});
assert.equal(result.ok, true);
assert.equal(calls[0].init.method, "POST");
assert.match(calls[0].url, /\/v1\/calendar\/events$/);
const invalid = await plugin.runTool("productier_create_calendar_event", {
title: "Bad",
startsAt: "2026-04-03T12:00:00Z",
endsAt: "2026-04-03T11:00:00Z"
});
assert.equal(invalid.ok, false);
assert.match(invalid.error.message, /endsAt must be greater than or equal to startsAt/i);
});
test("runs productier_create_note", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: {
id: "note-new",
workspaceSlug: "personal",
title: "Changelog",
content: "Draft"
}
}, 201);
}
});
const result = await plugin.runTool("productier_create_note", {
title: "Changelog",
content: "Draft"
});
assert.equal(result.ok, true);
assert.equal(calls[0].init.method, "POST");
assert.match(calls[0].url, /\/v1\/notes$/);
});
test("runs productier_connect_mailbox with defaults", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: {
id: "mb-1",
workspaceSlug: "personal",
label: "team@example.com",
email: "team@example.com"
}
}, 201);
}
});
const result = await plugin.runTool("productier_connect_mailbox", {
email: "team@example.com",
imapHost: "imap.example.com",
imapPassword: "imap-secret",
smtpHost: "smtp.example.com"
});
assert.equal(result.ok, true);
assert.equal(result.data.connected, true);
assert.equal(calls[0].init.method, "POST");
assert.match(calls[0].url, /\/v1\/mailboxes$/);
assert.deepEqual(JSON.parse(calls[0].init.body), {
workspaceSlug: "personal",
email: "team@example.com",
imapHost: "imap.example.com",
imapPort: 993,
imapPassword: "imap-secret",
imapUseTls: true,
smtpHost: "smtp.example.com",
smtpPort: 587,
smtpUseTls: true
});
});
test("runs productier_sync_mailbox", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: {
id: "mb-1",
syncStatus: "ready"
}
});
}
});
const result = await plugin.runTool("productier_sync_mailbox", {
mailboxId: "mb-1"
});
assert.equal(result.ok, true);
assert.equal(result.data.synced, true);
assert.equal(calls[0].init.method, "POST");
assert.match(calls[0].url, /\/v1\/mailboxes\/mb-1\/sync$/);
});
test("runs productier_create_outgoing_mail and normalizes recipients", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
defaultWorkspaceSlug: "personal",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: {
id: "out-3",
workspaceSlug: "personal",
mailboxId: "mb-1",
status: "scheduled"
}
}, 201);
}
});
const result = await plugin.runTool("productier_create_outgoing_mail", {
mailboxId: "mb-1",
to: "Alex <alex@example.com>, jamie@example.com",
cc: [{ email: "ops@example.com", name: "Ops" }],
subject: "Plan",
textBody: "Draft",
scheduledFor: "2026-04-10T09:00:00Z"
});
assert.equal(result.ok, true);
assert.equal(result.data.queued, true);
assert.equal(calls[0].init.method, "POST");
assert.match(calls[0].url, /\/v1\/mail\/outgoing$/);
assert.deepEqual(JSON.parse(calls[0].init.body), {
workspaceSlug: "personal",
mailboxId: "mb-1",
to: [
{ name: "Alex", email: "alex@example.com" },
{ email: "jamie@example.com" }
],
cc: [{ name: "Ops", email: "ops@example.com" }],
subject: "Plan",
textBody: "Draft",
scheduledFor: "2026-04-10T09:00:00Z"
});
});
test("runs productier_create_task_from_mail with defaults", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
defaultBoardGroupId: "group-inbox",
defaultWorkspaceSlug: "personal",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: {
id: "task-from-mail",
workspaceSlug: "personal",
boardGroupId: "group-inbox",
title: "From mail"
}
}, 201);
}
});
const result = await plugin.runTool("productier_create_task_from_mail", {
messageId: "mail-1"
});
assert.equal(result.ok, true);
assert.equal(result.data.sourceMessageId, "mail-1");
assert.equal(result.data.workspaceSlug, "personal");
assert.equal(calls[0].init.method, "POST");
assert.match(calls[0].url, /\/v1\/mail\/messages\/mail-1\/create-task$/);
assert.deepEqual(JSON.parse(calls[0].init.body), {
boardGroupId: "group-inbox"
});
});
test("runs productier_update_board_group with partial fields", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: { id: "group-2", name: "Doing", color: "sky", order: 1 }
});
}
});
const result = await plugin.runTool("productier_update_board_group", {
groupId: "group-2",
name: "Doing"
});
assert.equal(result.ok, true);
assert.equal(calls[0].init.method, "PATCH");
assert.match(calls[0].url, /\/v1\/board-groups\/group-2$/);
});
test("runs productier_update_task with partial fields", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: {
id: "task-11",
workspaceSlug: "personal",
boardGroupId: "group-inbox",
title: "Renamed",
description: "Body",
status: "done",
color: "sky",
labelIds: [],
attachments: [],
comments: [],
createdAt: "2026-03-01T08:00:00Z",
updatedAt: "2026-03-01T08:00:00Z"
}
});
}
});
const result = await plugin.runTool("productier_update_task", {
taskId: "task-11",
status: "done",
title: "Renamed"
});
assert.equal(result.ok, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].init.method, "PATCH");
assert.match(calls[0].url, /\/v1\/tasks\/task-11$/);
assert.deepEqual(JSON.parse(calls[0].init.body), {
title: "Renamed",
status: "done"
});
});
test("runs productier_update_calendar_event and validates empty patch", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: { id: "ev-1", title: "Updated", startsAt: "2026-04-03T10:00:00Z", endsAt: "2026-04-03T11:00:00Z" }
});
}
});
const result = await plugin.runTool("productier_update_calendar_event", {
eventId: "ev-1",
title: "Updated"
});
assert.equal(result.ok, true);
assert.equal(calls[0].init.method, "PATCH");
assert.match(calls[0].url, /\/v1\/calendar\/events\/ev-1$/);
const invalid = await plugin.runTool("productier_update_calendar_event", { eventId: "ev-1" });
assert.equal(invalid.ok, false);
assert.match(invalid.error.message, /at least one field/i);
});
test("runs productier_update_note and validates empty patch", async () => {
const calls = [];
const plugin = createPlugin({
profile: "standard",
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return createJSONResponse({
data: { id: "note-1", title: "Renamed", content: "Body" }
});
}
});
const result = await plugin.runTool("productier_update_note", {
noteId: "note-1",
title: "Renamed"
});
assert.equal(result.ok, true);
assert.equal(calls[0].init.method, "PATCH");
assert.match(calls[0].url, /\/v1\/notes\/note-1$/);
const invalid = await plugin.runTool("productier_update_note", { noteId: "note-1" });
assert.equal(invalid.ok, false);
assert.match(invalid.error.message, /at least one field/i);
});
test("maps backend structured errors", async () => {
const plugin = createPlugin({
fetchImpl: async () =>
createJSONResponse({
error: {
code: "unauthorized",
message: "authentication required",
requestId: "req-123"
}
}, 401)
});
const result = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
assert.equal(result.ok, false);
assert.equal(result.error.code, "unauthorized");
assert.equal(result.error.status, 401);
assert.equal(result.error.requestId, "req-123");
});
test("returns validation error when workspace slug is missing", async () => {
const plugin = createPlugin({
fetchImpl: async () => createJSONResponse({ data: [] })
});
const result = await plugin.runTool("productier_list_tasks", {});
assert.equal(result.ok, false);
assert.equal(result.error.code, "tool_execution_error");
assert.match(result.error.message, /workspaceSlug is required/i);
});
test("returns validation error for invalid update task status", async () => {
const plugin = createPlugin({
profile: "standard",
fetchImpl: async () => createJSONResponse({ data: [] })
});
const result = await plugin.runTool("productier_update_task", {
taskId: "task-1",
status: "bad_status"
});
assert.equal(result.ok, false);
assert.equal(result.error.code, "tool_execution_error");
assert.match(result.error.message, /status must be one of/i);
});
test("returns validation error for invalid mail linked filter", async () => {
const plugin = createPlugin({
profile: "standard",
fetchImpl: async () => createJSONResponse({ data: [] })
});
const result = await plugin.runTool("productier_list_mail_messages", {
workspaceSlug: "personal",
linked: "bad-value"
});
assert.equal(result.ok, false);
assert.equal(result.error.code, "tool_execution_error");
assert.match(result.error.message, /linked must be one of/i);
});
test("returns validation error for invalid mailbox sync filter", async () => {
const plugin = createPlugin({
profile: "standard",
fetchImpl: async () => createJSONResponse({ data: [] })
});
const result = await plugin.runTool("productier_list_mailboxes", {
workspaceSlug: "personal",
syncStatus: "bad-value"
});
assert.equal(result.ok, false);
assert.equal(result.error.code, "tool_execution_error");
assert.match(result.error.message, /syncStatus must be one of/i);
});
test("returns validation error for invalid outgoing status filter", async () => {
const plugin = createPlugin({
profile: "standard",
fetchImpl: async () => createJSONResponse({ data: [] })
});
const result = await plugin.runTool("productier_list_outgoing_mails", {
workspaceSlug: "personal",
status: "bad-value"
});
assert.equal(result.ok, false);
assert.equal(result.error.code, "tool_execution_error");
assert.match(result.error.message, /status must be one of/i);
});
test("retries transient backend errors and succeeds", async () => {
let calls = 0;
const plugin = createPlugin({
retryMaxAttempts: 3,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0,
retryJitterMs: 0,
fetchImpl: async () => {
calls += 1;
if (calls === 1) {
return createJSONResponse(
{
error: {
code: "service_unavailable",
message: "temporary outage"
}
},
503,
);
}
return createJSONResponse({
data: [{ id: "ws-1", slug: "personal", name: "Personal HQ", role: "owner", createdAt: "2026-01-01T00:00:00Z" }]
});
}
});
const result = await plugin.runTool("productier_list_workspaces", {});
assert.equal(result.ok, true);
assert.equal(calls, 2);
assert.equal(result.meta.attempts, 2);
});
test("returns retry metadata when retries are exhausted", async () => {
const plugin = createPlugin({
retryMaxAttempts: 2,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0,
retryJitterMs: 0,
fetchImpl: async () =>
createJSONResponse(
{
error: {
code: "service_unavailable",
message: "temporary outage"
}
},
503,
)
});
const result = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
assert.equal(result.ok, false);
assert.equal(result.error.code, "service_unavailable");
assert.equal(result.error.status, 503);
assert.equal(result.error.retryable, true);
assert.equal(result.error.attempts, 2);
});
test("enforces per-tool rate limits", async () => {
let now = 1_000;
let fetchCalls = 0;
const plugin = createPlugin({
rateLimitMaxCalls: 1,
rateLimitWindowMs: 60_000,
nowFn: () => now,
fetchImpl: async () => {
fetchCalls += 1;
return createJSONResponse({ data: [] });
}
});
const first = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
assert.equal(first.ok, true);
const second = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
assert.equal(second.ok, false);
assert.equal(second.error.code, "tool_rate_limited");
assert.equal(second.error.status, 429);
assert.ok(second.error.retryAfterMs >= 1);
now += 60_001;
const third = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
assert.equal(third.ok, true);
assert.equal(fetchCalls, 2);
});
test("writes structured audit entries for success and failure", async () => {
const auditEntries = [];
const plugin = createPlugin({
profile: "standard",
auditLogFn: async entry => {
auditEntries.push(entry);
},
fetchImpl: async () => createJSONResponse({ data: [] })
});
const success = await plugin.runTool("productier_list_workspaces", {});
assert.equal(success.ok, true);
const failure = await plugin.runTool("productier_create_task", {});
assert.equal(failure.ok, false);
assert.equal(failure.error.code, "tool_execution_error");
assert.equal(auditEntries.length, 2);
assert.equal(auditEntries[0].tool, "productier_list_workspaces");
assert.equal(auditEntries[0].ok, true);
assert.equal(auditEntries[1].tool, "productier_create_task");
assert.equal(auditEntries[1].ok, false);
assert.equal(auditEntries[1].errorCode, "tool_execution_error");
});