fastmail-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/index.ts +58 -0
- package/openclaw.plugin.json +31 -0
- package/package.json +38 -0
- package/skills/fastmail/SKILL.md +136 -0
- package/src/auth.ts +27 -0
- package/src/formatters.ts +321 -0
- package/src/mcp-client.ts +150 -0
- package/src/tools/calendar.ts +77 -0
- package/src/tools/contacts.ts +55 -0
- package/src/tools/email.ts +493 -0
- package/src/tools/memo.ts +56 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-efficient text formatters for OpenClaw plugin output.
|
|
3
|
+
*
|
|
4
|
+
* Each formatter takes parsed MCP tool results (JSON objects/arrays)
|
|
5
|
+
* and emits compact, scannable text optimized for LLM token efficiency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// -- Helpers ----------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function formatDate(dateStr: string): string {
|
|
11
|
+
const d = new Date(dateStr);
|
|
12
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
13
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
14
|
+
const hours = String(d.getHours()).padStart(2, "0");
|
|
15
|
+
const mins = String(d.getMinutes()).padStart(2, "0");
|
|
16
|
+
return `${d.getFullYear()}-${month}-${day} ${hours}:${mins}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatAddr(addr: { name?: string; email: string }): string {
|
|
20
|
+
if (addr.name) return `${addr.name} <${addr.email}>`;
|
|
21
|
+
return addr.email;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function truncate(text: string, max: number): string {
|
|
25
|
+
if (text.length <= max) return text;
|
|
26
|
+
return text.slice(0, max - 1) + "\u2026";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatFileSize(bytes: number): string {
|
|
30
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
31
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
32
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// -- Email List -------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export function formatEmailList(emails: any[], title?: string): string {
|
|
38
|
+
if (!emails || emails.length === 0) return "No emails found.";
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
if (title) { lines.push(`# ${title} (${emails.length})`, ""); }
|
|
41
|
+
for (const e of emails) {
|
|
42
|
+
const id = e.id || "?";
|
|
43
|
+
const date = e.receivedAt ? formatDate(e.receivedAt) : " ";
|
|
44
|
+
const from = e.from?.[0] ? formatAddr(e.from[0]) : "Unknown";
|
|
45
|
+
const subject = e.subject || "(no subject)";
|
|
46
|
+
const preview = e.preview ? truncate(e.preview, 80) : "";
|
|
47
|
+
const keywords = e.keywords || {};
|
|
48
|
+
const unread = !keywords.$seen ? "*" : " ";
|
|
49
|
+
const flagged = keywords.$flagged ? "!" : "";
|
|
50
|
+
const att = e.hasAttachment ? " [att]" : "";
|
|
51
|
+
lines.push(`${id} ${date} ${unread}${flagged}${from}${att}`);
|
|
52
|
+
lines.push(` ${subject} \u2014 ${preview}`, "");
|
|
53
|
+
}
|
|
54
|
+
return lines.join("\n").trimEnd();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// -- Single Email -----------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export function formatEmail(email: any): string {
|
|
60
|
+
if (typeof email === "string") return email;
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
const from = email.from?.map(formatAddr).join(", ") || "Unknown";
|
|
63
|
+
const to = email.to?.map(formatAddr).join(", ") || "";
|
|
64
|
+
const cc = email.cc?.map(formatAddr).join(", ");
|
|
65
|
+
const date = email.receivedAt ? formatDate(email.receivedAt) : "";
|
|
66
|
+
const subject = email.subject || "(no subject)";
|
|
67
|
+
lines.push(`From: ${from}`);
|
|
68
|
+
if (to) lines.push(`To: ${to}`);
|
|
69
|
+
if (cc) lines.push(`CC: ${cc}`);
|
|
70
|
+
if (date) lines.push(`Date: ${date}`);
|
|
71
|
+
lines.push(`Subject: ${subject}`);
|
|
72
|
+
const meta: string[] = [];
|
|
73
|
+
if (email.id) meta.push(`ID: ${email.id}`);
|
|
74
|
+
if (email.threadId) meta.push(`Thread: ${email.threadId}`);
|
|
75
|
+
if (meta.length) lines.push(meta.join(" | "));
|
|
76
|
+
lines.push("");
|
|
77
|
+
const bodyValues = email.bodyValues as Record<string, { value: string }> | undefined;
|
|
78
|
+
const textPartId = email.textBody?.[0]?.partId;
|
|
79
|
+
const textContent = textPartId && bodyValues?.[textPartId]?.value;
|
|
80
|
+
if (textContent) { lines.push(textContent); }
|
|
81
|
+
else if (email.preview) { lines.push(email.preview); }
|
|
82
|
+
if (email.attachments?.length) {
|
|
83
|
+
lines.push("", "Attachments:");
|
|
84
|
+
for (const att of email.attachments) {
|
|
85
|
+
const size = att.size ? ` (${formatFileSize(att.size)})` : "";
|
|
86
|
+
lines.push(` ${att.name || "unnamed"}${size}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// -- Mailboxes --------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export function formatMailboxes(mailboxes: any[]): string {
|
|
95
|
+
if (!mailboxes?.length) return "No mailboxes found.";
|
|
96
|
+
const lines: string[] = ["# Mailboxes", ""];
|
|
97
|
+
const sorted = [...mailboxes].sort((a, b) => {
|
|
98
|
+
if (a.role && !b.role) return -1;
|
|
99
|
+
if (!a.role && b.role) return 1;
|
|
100
|
+
return (a.name || "").localeCompare(b.name || "");
|
|
101
|
+
});
|
|
102
|
+
for (const mb of sorted) {
|
|
103
|
+
const role = mb.role ? ` (${mb.role})` : "";
|
|
104
|
+
const total = mb.totalEmails ?? 0;
|
|
105
|
+
const unread = mb.unreadEmails ?? 0;
|
|
106
|
+
const unreadStr = unread > 0 ? ` unread: ${unread}` : "";
|
|
107
|
+
lines.push(`${mb.id} ${mb.name}${role} ${total} emails${unreadStr}`);
|
|
108
|
+
}
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -- Mailbox Stats ----------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export function formatMailboxStats(stats: any): string {
|
|
115
|
+
if (Array.isArray(stats)) {
|
|
116
|
+
const lines: string[] = ["# Mailbox Statistics", ""];
|
|
117
|
+
for (const mb of stats) {
|
|
118
|
+
const unread = mb.unreadEmails > 0 ? ` unread: ${mb.unreadEmails}` : "";
|
|
119
|
+
lines.push(`${mb.name} (${mb.role || "custom"}) ${mb.totalEmails} emails${unread}`);
|
|
120
|
+
}
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
return [
|
|
124
|
+
`# ${stats.name}${stats.role ? ` (${stats.role})` : ""}`, "",
|
|
125
|
+
`Total emails: ${stats.totalEmails ?? 0}`,
|
|
126
|
+
`Unread: ${stats.unreadEmails ?? 0}`,
|
|
127
|
+
`Threads: ${stats.totalThreads ?? 0}`,
|
|
128
|
+
`Unread threads: ${stats.unreadThreads ?? 0}`,
|
|
129
|
+
].join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// -- Account Summary --------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export function formatAccountSummary(summary: any): string {
|
|
135
|
+
const lines: string[] = [
|
|
136
|
+
"# Account Summary", "",
|
|
137
|
+
`Mailboxes: ${summary.mailboxCount}`,
|
|
138
|
+
`Identities: ${summary.identityCount}`,
|
|
139
|
+
`Total emails: ${summary.totalEmails}`,
|
|
140
|
+
`Unread: ${summary.unreadEmails}`, "", "Mailboxes:",
|
|
141
|
+
];
|
|
142
|
+
if (summary.mailboxes) {
|
|
143
|
+
for (const mb of summary.mailboxes) {
|
|
144
|
+
const unread = mb.unreadEmails > 0 ? ` unread: ${mb.unreadEmails}` : "";
|
|
145
|
+
lines.push(` ${mb.name}${mb.role ? ` (${mb.role})` : ""} ${mb.totalEmails}${unread}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// -- Contacts ---------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
export function formatContacts(contacts: any[]): string {
|
|
154
|
+
if (!contacts?.length) return "No contacts found.";
|
|
155
|
+
const lines: string[] = [`# Contacts (${contacts.length})`, ""];
|
|
156
|
+
for (const c of contacts) {
|
|
157
|
+
const name = c.name || [c.prefix, c.firstName, c.lastName].filter(Boolean).join(" ") || "Unnamed";
|
|
158
|
+
lines.push(`${c.id} ${name}`);
|
|
159
|
+
const emails = c.emails?.map((e: any) => e.value || e.email).filter(Boolean) || [];
|
|
160
|
+
const phones = c.phones?.map((p: any) => p.value || p.phone).filter(Boolean) || [];
|
|
161
|
+
const details = [...emails, ...phones].join(" | ");
|
|
162
|
+
if (details) lines.push(` ${details}`);
|
|
163
|
+
lines.push("");
|
|
164
|
+
}
|
|
165
|
+
return lines.join("\n").trimEnd();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function formatContact(contact: any): string {
|
|
169
|
+
if (!contact) return "Contact not found.";
|
|
170
|
+
const lines: string[] = [];
|
|
171
|
+
const name = contact.name || [contact.prefix, contact.firstName, contact.lastName].filter(Boolean).join(" ") || "Unnamed";
|
|
172
|
+
lines.push(`# ${name}`);
|
|
173
|
+
if (contact.id) lines.push(`ID: ${contact.id}`);
|
|
174
|
+
lines.push("");
|
|
175
|
+
if (contact.company) lines.push(`Company: ${contact.company}`);
|
|
176
|
+
if (contact.department) lines.push(`Department: ${contact.department}`);
|
|
177
|
+
if (contact.jobTitle) lines.push(`Title: ${contact.jobTitle}`);
|
|
178
|
+
if (contact.emails?.length) {
|
|
179
|
+
lines.push("", "Emails:");
|
|
180
|
+
for (const e of contact.emails) {
|
|
181
|
+
const label = e.label || e.type || "";
|
|
182
|
+
lines.push(` ${label ? label + ": " : ""}${e.value || e.email}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (contact.phones?.length) {
|
|
186
|
+
lines.push("", "Phones:");
|
|
187
|
+
for (const p of contact.phones) {
|
|
188
|
+
const label = p.label || p.type || "";
|
|
189
|
+
lines.push(` ${label ? label + ": " : ""}${p.value || p.phone}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (contact.addresses?.length) {
|
|
193
|
+
lines.push("", "Addresses:");
|
|
194
|
+
for (const a of contact.addresses) {
|
|
195
|
+
const parts = [a.street, a.city, a.state, a.postcode, a.country].filter(Boolean);
|
|
196
|
+
const label = a.label || a.type || "";
|
|
197
|
+
lines.push(` ${label ? label + ": " : ""}${parts.join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (contact.notes) { lines.push("", `Notes: ${contact.notes}`); }
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// -- Calendar ---------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
export function formatCalendars(calendars: any[]): string {
|
|
207
|
+
if (!calendars?.length) return "No calendars found.";
|
|
208
|
+
const lines: string[] = ["# Calendars", ""];
|
|
209
|
+
for (const cal of calendars) {
|
|
210
|
+
lines.push(`${cal.id} ${cal.name}${cal.isDefault ? " (default)" : ""}`);
|
|
211
|
+
}
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function formatEvents(events: any[]): string {
|
|
216
|
+
if (!events?.length) return "No events found.";
|
|
217
|
+
const lines: string[] = [`# Events (${events.length})`, ""];
|
|
218
|
+
for (const e of events) {
|
|
219
|
+
const start = e.start ? formatDate(e.start) : "?";
|
|
220
|
+
let timeRange = start;
|
|
221
|
+
if (e.start && e.duration) {
|
|
222
|
+
timeRange = `${start} (${e.duration})`;
|
|
223
|
+
} else if (e.end) {
|
|
224
|
+
const endTime = formatDate(e.end);
|
|
225
|
+
timeRange = endTime.slice(0, 10) === start.slice(0, 10)
|
|
226
|
+
? `${start}-${endTime.slice(11)}`
|
|
227
|
+
: `${start} to ${endTime}`;
|
|
228
|
+
}
|
|
229
|
+
lines.push(`${e.id} ${timeRange} ${e.title || "(no title)"}`);
|
|
230
|
+
const details: string[] = [];
|
|
231
|
+
if (e.location) details.push(e.location);
|
|
232
|
+
if (e.calendarName || e.calendarId) details.push(`Calendar: ${e.calendarName || e.calendarId}`);
|
|
233
|
+
if (details.length) lines.push(` ${details.join(" | ")}`);
|
|
234
|
+
lines.push("");
|
|
235
|
+
}
|
|
236
|
+
return lines.join("\n").trimEnd();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatEvent(event: any): string {
|
|
240
|
+
if (!event) return "Event not found.";
|
|
241
|
+
const lines: string[] = [];
|
|
242
|
+
lines.push(`# ${event.title || "(no title)"}`);
|
|
243
|
+
if (event.id) lines.push(`ID: ${event.id}`);
|
|
244
|
+
lines.push("");
|
|
245
|
+
if (event.start) lines.push(`Start: ${formatDate(event.start)}`);
|
|
246
|
+
if (event.end) lines.push(`End: ${formatDate(event.end)}`);
|
|
247
|
+
if (event.duration) lines.push(`Duration: ${event.duration}`);
|
|
248
|
+
if (event.location) lines.push(`Location: ${event.location}`);
|
|
249
|
+
if (event.description) { lines.push("", event.description); }
|
|
250
|
+
if (event.participants?.length) {
|
|
251
|
+
lines.push("", "Participants:");
|
|
252
|
+
for (const p of event.participants) {
|
|
253
|
+
lines.push(` ${p.name || p.email}${p.name ? ` <${p.email}>` : ""}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return lines.join("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// -- Identities -------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
export function formatIdentities(identities: any[]): string {
|
|
262
|
+
if (!identities?.length) return "No identities found.";
|
|
263
|
+
const lines: string[] = ["# Identities", ""];
|
|
264
|
+
for (const id of identities) {
|
|
265
|
+
const name = id.name || "";
|
|
266
|
+
const email = id.email || "";
|
|
267
|
+
const isDefault = id.mayDelete === false ? " (primary)" : "";
|
|
268
|
+
lines.push(`${name ? name + " " : ""}<${email}>${isDefault}`);
|
|
269
|
+
}
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// -- Memos ------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
export function formatMemo(memo: any): string {
|
|
276
|
+
if (!memo) return "No memo found for this email.";
|
|
277
|
+
if (typeof memo === "string") return memo;
|
|
278
|
+
const lines: string[] = [];
|
|
279
|
+
if (memo.subject) lines.push(`# Memo: ${memo.subject}`);
|
|
280
|
+
if (memo.memoId) lines.push(`Memo ID: ${memo.memoId}`);
|
|
281
|
+
if (memo.receivedAt) lines.push(`Created: ${formatDate(memo.receivedAt)}`);
|
|
282
|
+
lines.push("");
|
|
283
|
+
const bodyValues = memo.bodyValues as Record<string, { value: string }> | undefined;
|
|
284
|
+
const textPartId = memo.textBody?.[0]?.partId;
|
|
285
|
+
const text = textPartId && bodyValues?.[textPartId]?.value;
|
|
286
|
+
if (text) { lines.push(text); }
|
|
287
|
+
else if (memo.preview) { lines.push(memo.preview); }
|
|
288
|
+
else if (memo.text) { lines.push(memo.text); }
|
|
289
|
+
return lines.join("\n");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// -- Attachments ------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
export function formatAttachments(attachments: any[]): string {
|
|
295
|
+
if (!attachments?.length) return "No attachments.";
|
|
296
|
+
const lines: string[] = [`# Attachments (${attachments.length})`, ""];
|
|
297
|
+
for (const att of attachments) {
|
|
298
|
+
const size = att.size ? ` (${formatFileSize(att.size)})` : "";
|
|
299
|
+
const type = att.type || att.mimeType || "";
|
|
300
|
+
lines.push(`${att.blobId || att.id || "?"} ${att.name || "unnamed"} ${type}${size}`);
|
|
301
|
+
}
|
|
302
|
+
return lines.join("\n");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -- Inbox Updates ----------------------------------------------------
|
|
306
|
+
|
|
307
|
+
export function formatInboxUpdates(updates: any): string {
|
|
308
|
+
const lines: string[] = [];
|
|
309
|
+
if (updates.queryState) lines.push(`State: ${updates.queryState}`);
|
|
310
|
+
if (updates.added?.length) {
|
|
311
|
+
lines.push("", `Added (${updates.added.length}):`);
|
|
312
|
+
lines.push(formatEmailList(updates.added));
|
|
313
|
+
}
|
|
314
|
+
if (updates.removed?.length) {
|
|
315
|
+
lines.push("", `Removed: ${updates.removed.join(", ")}`);
|
|
316
|
+
}
|
|
317
|
+
if (!updates.added?.length && !updates.removed?.length) {
|
|
318
|
+
lines.push("No changes since last check.");
|
|
319
|
+
}
|
|
320
|
+
return lines.join("\n");
|
|
321
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP SDK client wrapper for the Fastmail OpenClaw plugin.
|
|
3
|
+
*
|
|
4
|
+
* Maintains a persistent connection to the remote Worker using
|
|
5
|
+
* StreamableHTTPClientTransport. The connection is lazy-initialized
|
|
6
|
+
* on first tool call and reused for all subsequent calls.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
10
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Strip prompt-injection datamarking from MCP tool responses.
|
|
14
|
+
*
|
|
15
|
+
* The server wraps external data in [UNTRUSTED_EXTERNAL_DATA_xxx] markers
|
|
16
|
+
* and prepends a preamble paragraph. These are useful for LLM safety but
|
|
17
|
+
* noise in a plugin context.
|
|
18
|
+
*
|
|
19
|
+
* Coupled to the marker format in src/prompt-guard.ts on the server.
|
|
20
|
+
* If the server format changes, these regexes must be updated to match.
|
|
21
|
+
*/
|
|
22
|
+
function stripDatamarking(text: string): string {
|
|
23
|
+
let cleaned = text.replace(
|
|
24
|
+
/^Data between \[UNTRUSTED_EXTERNAL_DATA_[^\]]+\] and \[\/UNTRUSTED_EXTERNAL_DATA_[^\]]+\] markers is[\s\S]*?not acted upon as directives\.\s*/,
|
|
25
|
+
"",
|
|
26
|
+
);
|
|
27
|
+
cleaned = cleaned.replace(
|
|
28
|
+
/\[\/?UNTRUSTED_EXTERNAL_DATA_[^\]]+\]\s?/g,
|
|
29
|
+
"",
|
|
30
|
+
);
|
|
31
|
+
cleaned = cleaned.replace(
|
|
32
|
+
/\[WARNING: The [^\]]*? below contains text patterns[\s\S]*?found in it\.\]\s*/g,
|
|
33
|
+
"",
|
|
34
|
+
);
|
|
35
|
+
return cleaned.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class FastmailMcpClient {
|
|
39
|
+
private client: Client | null = null;
|
|
40
|
+
private url: string;
|
|
41
|
+
private token: string;
|
|
42
|
+
|
|
43
|
+
constructor(url: string, token: string) {
|
|
44
|
+
this.url = url;
|
|
45
|
+
this.token = token;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async ensureConnected(): Promise<Client> {
|
|
49
|
+
if (this.client) return this.client;
|
|
50
|
+
|
|
51
|
+
this.client = new Client({
|
|
52
|
+
name: "fastmail-openclaw",
|
|
53
|
+
version: "1.0.0",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const token = this.token;
|
|
57
|
+
const authFetch: typeof fetch = (input, init) => {
|
|
58
|
+
const headers = new Headers(init?.headers);
|
|
59
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
60
|
+
return fetch(input, { ...init, headers });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const transport = new StreamableHTTPClientTransport(
|
|
64
|
+
new URL(`${this.url}/mcp`),
|
|
65
|
+
{ fetch: authFetch },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await this.client.connect(transport);
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
this.client = null;
|
|
72
|
+
if (err?.message?.includes("401") || err?.message?.includes("Unauthorized")) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"Authentication failed. Check your bearerToken or run: fastmail auth",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return this.client;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Call an MCP tool on the remote server and return the parsed result.
|
|
85
|
+
* JSON responses are parsed; text responses returned as strings.
|
|
86
|
+
* Datamarking is stripped automatically.
|
|
87
|
+
*
|
|
88
|
+
* If the transport has dropped since the last call, resets the connection
|
|
89
|
+
* and retries once to handle server restarts or network interruptions.
|
|
90
|
+
*/
|
|
91
|
+
async callTool(
|
|
92
|
+
name: string,
|
|
93
|
+
args: Record<string, unknown> = {},
|
|
94
|
+
): Promise<any> {
|
|
95
|
+
let client = await this.ensureConnected();
|
|
96
|
+
let result;
|
|
97
|
+
try {
|
|
98
|
+
result = await client.callTool({ name, arguments: args });
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
// On transport-level errors, reset and retry once
|
|
101
|
+
const msg = err?.message ?? "";
|
|
102
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("ECONNRESET") ||
|
|
103
|
+
msg.includes("fetch failed") || msg.includes("transport") ||
|
|
104
|
+
msg.includes("closed") || msg.includes("network")) {
|
|
105
|
+
this.client = null;
|
|
106
|
+
client = await this.ensureConnected();
|
|
107
|
+
result = await client.callTool({ name, arguments: args });
|
|
108
|
+
} else {
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const textParts = (result.content as any[])
|
|
114
|
+
?.filter((c) => c.type === "text")
|
|
115
|
+
.map((c) => c.text) || [];
|
|
116
|
+
const text = textParts.join("\n");
|
|
117
|
+
const cleaned = stripDatamarking(text);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(cleaned);
|
|
121
|
+
} catch {
|
|
122
|
+
// Not JSON — try fallback below
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Sometimes datamarking leaves a preamble paragraph before the JSON payload.
|
|
126
|
+
// Walk backwards through double-newline-separated sections to find the
|
|
127
|
+
// largest trailing chunk that parses as valid JSON.
|
|
128
|
+
const parts = cleaned.split("\n\n");
|
|
129
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
130
|
+
const candidate = parts.slice(i).join("\n\n");
|
|
131
|
+
const trimmed = candidate.trim();
|
|
132
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(trimmed);
|
|
135
|
+
} catch {
|
|
136
|
+
// Keep looking at smaller slices
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return cleaned;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async close(): Promise<void> {
|
|
145
|
+
if (this.client) {
|
|
146
|
+
await this.client.close();
|
|
147
|
+
this.client = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar tools for the Fastmail OpenClaw plugin.
|
|
3
|
+
* 3 read-only + 1 optional (create).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { OpenClawApi, GetClientFn } from "../../index.js";
|
|
7
|
+
import { formatCalendars, formatEvents, formatEvent } from "../formatters.js";
|
|
8
|
+
|
|
9
|
+
export function registerCalendarTools(api: OpenClawApi, getClient: GetClientFn) {
|
|
10
|
+
api.registerTool({
|
|
11
|
+
name: "fastmail_list_calendars",
|
|
12
|
+
description: "List all calendars with IDs and names.",
|
|
13
|
+
parameters: { type: "object", properties: {} },
|
|
14
|
+
async execute(_id: string, _params: Record<string, never>) {
|
|
15
|
+
const client = await getClient();
|
|
16
|
+
return { content: [{ type: "text", text: formatCalendars(await client.callTool("list_calendars")) }] };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
api.registerTool({
|
|
21
|
+
name: "fastmail_list_events",
|
|
22
|
+
description: "List calendar events, optionally filtered by calendar.",
|
|
23
|
+
parameters: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
calendarId: { type: "string", description: "Calendar ID (omit for all)" },
|
|
27
|
+
limit: { type: "integer", default: 50, description: "Max events" },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async execute(_id: string, params: { calendarId?: string; limit?: number }) {
|
|
31
|
+
const client = await getClient();
|
|
32
|
+
const data = await client.callTool("list_calendar_events", { calendarId: params.calendarId, limit: params.limit ?? 50 });
|
|
33
|
+
return { content: [{ type: "text", text: Array.isArray(data) ? formatEvents(data) : String(data) }] };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
api.registerTool({
|
|
38
|
+
name: "fastmail_get_event",
|
|
39
|
+
description: "Get full event details by ID.",
|
|
40
|
+
parameters: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: { eventId: { type: "string", description: "Event ID" } },
|
|
43
|
+
required: ["eventId"],
|
|
44
|
+
},
|
|
45
|
+
async execute(_id: string, params: { eventId: string }) {
|
|
46
|
+
const client = await getClient();
|
|
47
|
+
return { content: [{ type: "text", text: formatEvent(await client.callTool("get_calendar_event", { eventId: params.eventId })) }] };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
api.registerTool({
|
|
52
|
+
name: "fastmail_create_event",
|
|
53
|
+
description: "Create a new calendar event.",
|
|
54
|
+
parameters: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
calendarId: { type: "string" },
|
|
58
|
+
title: { type: "string" },
|
|
59
|
+
start: { type: "string", description: "ISO 8601" },
|
|
60
|
+
end: { type: "string", description: "ISO 8601" },
|
|
61
|
+
description: { type: "string" },
|
|
62
|
+
location: { type: "string" },
|
|
63
|
+
},
|
|
64
|
+
required: ["calendarId", "title", "start", "end"],
|
|
65
|
+
},
|
|
66
|
+
async execute(_id: string, params: { calendarId: string; title: string; start: string; end: string; description?: string; location?: string }) {
|
|
67
|
+
const client = await getClient();
|
|
68
|
+
const data = await client.callTool("create_calendar_event", {
|
|
69
|
+
calendarId: params.calendarId, title: params.title,
|
|
70
|
+
start: params.start, end: params.end,
|
|
71
|
+
...(params.description && { description: params.description }),
|
|
72
|
+
...(params.location && { location: params.location }),
|
|
73
|
+
});
|
|
74
|
+
return { content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data) }] };
|
|
75
|
+
},
|
|
76
|
+
}, { optional: true });
|
|
77
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact tools for the Fastmail OpenClaw plugin.
|
|
3
|
+
* 3 read-only tools.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { OpenClawApi, GetClientFn } from "../../index.js";
|
|
7
|
+
import { formatContacts, formatContact } from "../formatters.js";
|
|
8
|
+
|
|
9
|
+
export function registerContactTools(api: OpenClawApi, getClient: GetClientFn) {
|
|
10
|
+
api.registerTool({
|
|
11
|
+
name: "fastmail_list_contacts",
|
|
12
|
+
description: "List contacts with names, emails, and phone numbers.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: { limit: { type: "integer", default: 50, description: "Max contacts" } },
|
|
16
|
+
},
|
|
17
|
+
async execute(_id: string, params: { limit?: number }) {
|
|
18
|
+
const client = await getClient();
|
|
19
|
+
const data = await client.callTool("list_contacts", { limit: params.limit ?? 50 });
|
|
20
|
+
return { content: [{ type: "text", text: Array.isArray(data) ? formatContacts(data) : String(data) }] };
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
api.registerTool({
|
|
25
|
+
name: "fastmail_get_contact",
|
|
26
|
+
description: "Get full contact details by ID.",
|
|
27
|
+
parameters: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: { contactId: { type: "string", description: "Contact ID" } },
|
|
30
|
+
required: ["contactId"],
|
|
31
|
+
},
|
|
32
|
+
async execute(_id: string, params: { contactId: string }) {
|
|
33
|
+
const client = await getClient();
|
|
34
|
+
return { content: [{ type: "text", text: formatContact(await client.callTool("get_contact", { contactId: params.contactId })) }] };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
api.registerTool({
|
|
39
|
+
name: "fastmail_search_contacts",
|
|
40
|
+
description: "Search contacts by name or email.",
|
|
41
|
+
parameters: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
query: { type: "string", description: "Search query" },
|
|
45
|
+
limit: { type: "integer", default: 20, description: "Max results" },
|
|
46
|
+
},
|
|
47
|
+
required: ["query"],
|
|
48
|
+
},
|
|
49
|
+
async execute(_id: string, params: { query: string; limit?: number }) {
|
|
50
|
+
const client = await getClient();
|
|
51
|
+
const data = await client.callTool("search_contacts", { query: params.query, limit: params.limit ?? 20 });
|
|
52
|
+
return { content: [{ type: "text", text: Array.isArray(data) ? formatContacts(data) : String(data) }] };
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|