clavarium-tether 0.1.0-next.2 → 0.1.0-next.20
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/bin/cli.mjs +63 -1
- package/host/connectors/calendar.mjs +196 -0
- package/host/connectors/contacts.mjs +154 -0
- package/host/connectors/graphmail.mjs +114 -0
- package/host/connectors/mail.mjs +315 -0
- package/host/imap.mjs +96 -0
- package/host/keeper.mjs +9 -0
- package/host/oauth.mjs +247 -0
- package/host/smtp.mjs +109 -0
- package/package.json +9 -5
- package/server/mcp.mjs +18 -2
- package/server/name.mjs +1 -1
- package/server/verbs.mjs +37 -1
package/bin/cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { ask, findFolder } from '../server/mcp.mjs';
|
|
10
10
|
import { NAME, VERSION, FOLDER, ENV_DIR } from '../server/name.mjs';
|
|
11
11
|
|
|
12
|
-
export const HELP = `${NAME}: act on the targets you were given (machines, folders, browsers).
|
|
12
|
+
export const HELP = `${NAME}: act on the targets you were given (machines, folders, browsers, mailboxes, calendars, address books).
|
|
13
13
|
|
|
14
14
|
${NAME} your targets, your level on each, its rules
|
|
15
15
|
${NAME} run TARGET "command" [--timeout S] run a command: its output and exit code
|
|
@@ -33,6 +33,19 @@ export const HELP = `${NAME}: act on the targets you were given (machines, folde
|
|
|
33
33
|
${NAME} browser TARGET key KEY [--selector S] [--tab N]
|
|
34
34
|
${NAME} browser TARGET scroll [--dx N] [--dy N] [--selector S] [--tab N]
|
|
35
35
|
${NAME} browser TARGET drag FROM TO [--tab N]
|
|
36
|
+
${NAME} mail TARGET list [--mailbox NAME] recent messages: who, subject, when
|
|
37
|
+
${NAME} mail TARGET read UID [--mailbox NAME] one message in full
|
|
38
|
+
${NAME} mail TARGET search "text" [--mailbox NAME]
|
|
39
|
+
${NAME} mail TARGET move UID MAILBOX move a message to a folder
|
|
40
|
+
${NAME} mail TARGET flag UID read|unread|flagged|unflagged
|
|
41
|
+
${NAME} mail TARGET send --to "a@b,c@d" --subject "..." --body "..." [--cc "..."] [--attach FILE]
|
|
42
|
+
${NAME} calendar TARGET list [--from DATE] [--to DATE] events in a window (default: from now on)
|
|
43
|
+
${NAME} calendar TARGET read ID
|
|
44
|
+
${NAME} calendar TARGET create --title "..." --start ISO --end ISO [--where "..."]
|
|
45
|
+
${NAME} calendar TARGET attach EVENT_ID FILE join a file to an event (Microsoft)
|
|
46
|
+
${NAME} contacts TARGET list name, emails, phones
|
|
47
|
+
${NAME} contacts TARGET read ID
|
|
48
|
+
${NAME} contacts TARGET attach CONTACT_ID FILE set a contact's photo (Microsoft)
|
|
36
49
|
${NAME} serve [--store DIR] [--port N] [--side FOLDER=NAME] [--local FOLDER[=TITLE]]
|
|
37
50
|
keep targets yourself, with their page (no application)
|
|
38
51
|
|
|
@@ -44,6 +57,11 @@ ${NAME} ${VERSION}`;
|
|
|
44
57
|
|
|
45
58
|
const FILE_VERBS = ['list', 'read', 'search', 'write', 'move', 'delete'];
|
|
46
59
|
const BROWSER_VERBS = ['tabs', 'open', 'shot', 'html', 'console', 'eval', 'click', 'type', 'key', 'scroll', 'drag'];
|
|
60
|
+
const MAIL_VERBS = ['list', 'read', 'search', 'move', 'flag', 'send'];
|
|
61
|
+
const CAL_VERBS = ['list', 'read', 'create', 'attach'];
|
|
62
|
+
const CONTACT_VERBS = ['list', 'read', 'attach'];
|
|
63
|
+
const baseName = (p) => String(p).split('/').pop();
|
|
64
|
+
const readB64 = (p) => { try { return readFileSync(p).toString('base64'); } catch { return null; } };
|
|
47
65
|
|
|
48
66
|
/* A command line, read: [verb, args] or { refused }. A --from file is read by run(). */
|
|
49
67
|
export function read(argv) {
|
|
@@ -97,6 +115,50 @@ export function read(argv) {
|
|
|
97
115
|
default: return need(p.length === 2, 'browser TARGET drag FROM TO') ?? ['browser.drag', { target, from: p[0], to: p[1], ...tab, ...why }];
|
|
98
116
|
}
|
|
99
117
|
}
|
|
118
|
+
case 'mail': {
|
|
119
|
+
const [what, ...p] = rest;
|
|
120
|
+
if (!target || !MAIL_VERBS.includes(what)) return { refused: `${NAME} mail TARGET ${MAIL_VERBS.join('|')} ...` };
|
|
121
|
+
const box = flags.mailbox !== undefined ? { mailbox: flags.mailbox } : {};
|
|
122
|
+
switch (what) {
|
|
123
|
+
case 'list': return ['mail.list', { target, ...box }];
|
|
124
|
+
case 'read': return need(p[0], 'mail TARGET read UID [--mailbox NAME]') ?? ['mail.read', { target, uid: p[0], ...box }];
|
|
125
|
+
case 'search': return need(p.length, 'mail TARGET search "text" [--mailbox NAME]') ?? ['mail.search', { target, text: p.join(' '), ...box }];
|
|
126
|
+
case 'move': return need(p.length === 2, 'mail TARGET move UID MAILBOX') ?? ['mail.move', { target, uid: p[0], mailbox: p[1], ...why }];
|
|
127
|
+
case 'flag': return need(p.length === 2, 'mail TARGET flag UID FLAG') ?? ['mail.flag', { target, uid: p[0], flag: p[1], ...why }];
|
|
128
|
+
default: {
|
|
129
|
+
const bad = need(flags.to !== undefined && flags.subject !== undefined && flags.body !== undefined, 'mail TARGET send --to "a@b" --subject "..." --body "..." [--cc "..."] [--attach FILE]');
|
|
130
|
+
if (bad) return bad;
|
|
131
|
+
let att = {};
|
|
132
|
+
if (flags.attach !== undefined) { const b = readB64(flags.attach); if (b === null) return { refused: `cannot read the file to attach: ${flags.attach}` }; att = { attach: b, attachName: flags['attach-name'] || baseName(flags.attach) }; }
|
|
133
|
+
return ['mail.send', { target, to: flags.to, subject: flags.subject, body: flags.body, ...(flags.cc !== undefined ? { cc: flags.cc } : {}), ...att, ...why }];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
case 'calendar': {
|
|
138
|
+
const [what, ...p] = rest;
|
|
139
|
+
if (!target || !CAL_VERBS.includes(what)) return { refused: `${NAME} calendar TARGET ${CAL_VERBS.join('|')} ...` };
|
|
140
|
+
switch (what) {
|
|
141
|
+
case 'list': return ['calendar.list', { target, ...(flags.from !== undefined ? { from: flags.from } : {}), ...(flags.to !== undefined ? { to: flags.to } : {}) }];
|
|
142
|
+
case 'read': return need(p[0], 'calendar TARGET read ID') ?? ['calendar.read', { target, id: p[0] }];
|
|
143
|
+
case 'attach': {
|
|
144
|
+
if (p.length < 2) return { refused: 'calendar TARGET attach EVENT_ID FILE' };
|
|
145
|
+
const b = readB64(p[1]); if (b === null) return { refused: `cannot read the file to attach: ${p[1]}` };
|
|
146
|
+
return ['calendar.attach', { target, id: p[0], attach: b, attachName: flags['attach-name'] || baseName(p[1]), ...why }];
|
|
147
|
+
}
|
|
148
|
+
default: return need(flags.title !== undefined && flags.start !== undefined && flags.end !== undefined, 'calendar TARGET create --title "..." --start ISO --end ISO [--where "..."]') ?? ['calendar.create', { target, title: flags.title, start: flags.start, end: flags.end, ...(flags.where !== undefined ? { where: flags.where } : {}), ...why }];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
case 'contacts': {
|
|
152
|
+
const [what, ...p] = rest;
|
|
153
|
+
if (!target || !CONTACT_VERBS.includes(what)) return { refused: `${NAME} contacts TARGET ${CONTACT_VERBS.join('|')} ...` };
|
|
154
|
+
if (what === 'list') return ['contacts.list', { target }];
|
|
155
|
+
if (what === 'attach') {
|
|
156
|
+
if (p.length < 2) return { refused: 'contacts TARGET attach CONTACT_ID FILE (a photo)' };
|
|
157
|
+
const b = readB64(p[1]); if (b === null) return { refused: `cannot read the file to attach: ${p[1]}` };
|
|
158
|
+
return ['contacts.attach', { target, id: p[0], attach: b, attachName: flags['attach-name'] || baseName(p[1]), ...why }];
|
|
159
|
+
}
|
|
160
|
+
return need(p[0], 'contacts TARGET read ID') ?? ['contacts.read', { target, id: p[0] }];
|
|
161
|
+
}
|
|
100
162
|
default: return { refused: `Unknown: ${verb}. Run "${NAME} --help".` };
|
|
101
163
|
}
|
|
102
164
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/* THE CALENDAR CONNECTOR: a person's calendar (Google or Microsoft) reached by the
|
|
2
|
+
* keeper over the provider's REST API with a token, never a password. Like the mail
|
|
3
|
+
* connector, the refresh token is the host's and the agent never sees it; a short
|
|
4
|
+
* access token is minted per call. Inspired by the MIT Google/Microsoft MCP servers,
|
|
5
|
+
* the calls reimplemented here so the token stays host-side and the levels gate them.
|
|
6
|
+
*
|
|
7
|
+
* target.config.provider 'google' or 'microsoft'
|
|
8
|
+
* target.config.clientId optional: an own OAuth app instead of Thunderbird's
|
|
9
|
+
* target.secrets.refresh the NAME of the refresh token in the host's secret store
|
|
10
|
+
* target.secrets.secret optional: the NAME of an own client secret
|
|
11
|
+
*
|
|
12
|
+
* Reads (watch): calendar.list, calendar.read. Acts (above watch): calendar.create.
|
|
13
|
+
* `fetch` is injected for tests. (Note: the Thunderbird client covers Google calendar
|
|
14
|
+
* cleanly; a Microsoft calendar needs the right Graph scope on the connected account.)
|
|
15
|
+
*/
|
|
16
|
+
import { no, Refused } from '../../server/verbs.mjs';
|
|
17
|
+
import { refreshAccess, thunderbird, PROVIDERS } from '../oauth.mjs';
|
|
18
|
+
|
|
19
|
+
const LIST_MAX = 25;
|
|
20
|
+
const READ_MAX = 256 * 1024;
|
|
21
|
+
const dateField = (s) => (/^\d{4}-\d{2}-\d{2}$/.test(s) ? { date: s } : { dateTime: s });
|
|
22
|
+
|
|
23
|
+
const API = {
|
|
24
|
+
google: {
|
|
25
|
+
list: (n, { from, to } = {}) => `https://www.googleapis.com/calendar/v3/calendars/primary/events?maxResults=${n}&singleEvents=true&orderBy=startTime&timeMin=${encodeURIComponent(from)}${to ? `&timeMax=${encodeURIComponent(to)}` : ''}`,
|
|
26
|
+
event: (id) => `https://www.googleapis.com/calendar/v3/calendars/primary/events/${encodeURIComponent(id)}`,
|
|
27
|
+
create: 'https://www.googleapis.com/calendar/v3/calendars/primary/events',
|
|
28
|
+
items: (j) => j.items ?? [],
|
|
29
|
+
brief: (e) => `${e.id}\n ${e.start?.dateTime ?? e.start?.date ?? '?'} — ${e.summary ?? '(no title)'}${e.location ? ` @ ${e.location}` : ''}`,
|
|
30
|
+
body: ({ title, start, end, where }) => ({ summary: title, start: dateField(start), end: dateField(end), ...(where ? { location: where } : {}) }),
|
|
31
|
+
},
|
|
32
|
+
microsoft: {
|
|
33
|
+
/* calendarView, not /events: /events orders over the WHOLE calendar, so it
|
|
34
|
+
returns the oldest events, not the next ones. calendarView takes a window
|
|
35
|
+
(from now on) and expands recurrences, so "list" means what is coming. */
|
|
36
|
+
list: (n, { from, to } = {}) => `https://graph.microsoft.com/v1.0/me/calendarView?startDateTime=${encodeURIComponent(from)}&endDateTime=${encodeURIComponent(to)}&$top=${n}&$orderby=start/dateTime&$select=id,subject,start,end,location`,
|
|
37
|
+
event: (id) => `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(id)}`,
|
|
38
|
+
create: 'https://graph.microsoft.com/v1.0/me/events',
|
|
39
|
+
items: (j) => j.value ?? [],
|
|
40
|
+
brief: (e) => `${e.id}\n ${e.start?.dateTime ?? '?'} — ${e.subject ?? '(no title)'}${e.location?.displayName ? ` @ ${e.location.displayName}` : ''}`,
|
|
41
|
+
body: ({ title, start, end, where }) => ({ subject: title, start: { dateTime: start, timeZone: 'UTC' }, end: { dateTime: end, timeZone: 'UTC' }, ...(where ? { location: { displayName: where } } : {}) }),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function calendar({ fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
46
|
+
const tokens = new Map();
|
|
47
|
+
const providerOf = (target) => { const p = String(target.config?.provider ?? ''); if (!API[p] || !PROVIDERS[p]) no(`${target.title}: set its provider to google or microsoft`); return p; };
|
|
48
|
+
|
|
49
|
+
async function access(target, ctx) {
|
|
50
|
+
const provider = providerOf(target);
|
|
51
|
+
const cached = tokens.get(target.id);
|
|
52
|
+
if (cached && cached.exp > Date.now() + 60_000) return cached.value;
|
|
53
|
+
const refresh = await ctx.secret('refresh');
|
|
54
|
+
if (!refresh) no(`${target.title}: its refresh token is empty -- reconnect the account`);
|
|
55
|
+
const clientId = target.config?.clientId || thunderbird(provider).clientId;
|
|
56
|
+
const clientSecret = target.secrets?.secret ? await ctx.secret('secret') : thunderbird(provider).clientSecret;
|
|
57
|
+
let t;
|
|
58
|
+
try { t = await refreshAccess(provider, { clientId, clientSecret, refreshToken: refresh, fetch: fetchImpl }); }
|
|
59
|
+
catch (e) { if (e instanceof Refused) throw e; no(`${target.title}: the calendar must be reconnected (its authorisation was refused)`); }
|
|
60
|
+
tokens.set(target.id, { value: t.accessToken, exp: t.expiresAt });
|
|
61
|
+
return t.accessToken;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function call(target, ctx, method, url, json) {
|
|
65
|
+
const token = await access(target, ctx);
|
|
66
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
67
|
+
if (json !== undefined) headers['Content-Type'] = 'application/json';
|
|
68
|
+
let res;
|
|
69
|
+
try { res = await fetchImpl(url, { method, headers, ...(json !== undefined ? { body: JSON.stringify(json) } : {}) }); }
|
|
70
|
+
catch (e) { if (e instanceof Refused) throw e; no(`${target.title}: cannot reach the calendar`); }
|
|
71
|
+
if (res.status === 401 || res.status === 403) no(`${target.title}: not authorized -- reconnect the account (the calendar scope may be missing)`);
|
|
72
|
+
return res;
|
|
73
|
+
}
|
|
74
|
+
const bodyError = async (res) => { try { const j = await res.json(); return j?.error?.message || ''; } catch { return ''; } };
|
|
75
|
+
|
|
76
|
+
/* GOOGLE CALENDAR OVER CalDAV. The REST Calendar API needs that API turned on
|
|
77
|
+
in the OAuth client's own Google project; the borrowed Thunderbird client is
|
|
78
|
+
a CalDAV client (that is how it reads Google calendars), not a REST one, so
|
|
79
|
+
REST is refused. CalDAV uses the same calendar scope the client carries (as
|
|
80
|
+
its contacts do over CardDAV), so the host speaks a small calendar-query and
|
|
81
|
+
parses the returned iCalendar. The collection URL needs the account address. */
|
|
82
|
+
const decodeXml = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
83
|
+
const userOf = (target) => { const u = String(target.config?.user ?? '').trim(); if (!u) no(`${target.title}: reconnect the account so it carries its address (the Google calendar needs it)`); return u; };
|
|
84
|
+
const CALDAV = (user) => `https://apidata.googleusercontent.com/caldav/v2/${encodeURIComponent(user)}/events/`;
|
|
85
|
+
const icalUTC = (iso) => new Date(iso).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
86
|
+
const fmtICal = (dt) => { const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2}))?/.exec(dt || ''); return m ? (m[4] ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}` : `${m[1]}-${m[2]}-${m[3]}`) : (dt || '?'); };
|
|
87
|
+
const veventField = (v, re) => { const m = re.exec(v); return m ? m[1].trim() : ''; };
|
|
88
|
+
function fromVevent(text) {
|
|
89
|
+
const v = String(text).replace(/\r?\n[ \t]/g, '');
|
|
90
|
+
return { summary: veventField(v, /^SUMMARY[^:\r\n]*:(.+)$/im), start: veventField(v, /^DTSTART[^:\r\n]*:(.+)$/im), location: veventField(v, /^LOCATION[^:\r\n]*:(.+)$/im) };
|
|
91
|
+
}
|
|
92
|
+
async function dav(target, ctx, method, url, { headers = {}, body } = {}) {
|
|
93
|
+
const token = await access(target, ctx);
|
|
94
|
+
let res;
|
|
95
|
+
try { res = await fetchImpl(url, { method, headers: { Authorization: `Bearer ${token}`, ...headers }, ...(body !== undefined ? { body } : {}) }); }
|
|
96
|
+
catch (e) { if (e instanceof Refused) throw e; no(`${target.title}: cannot reach the calendar`); }
|
|
97
|
+
if (res.status === 401 || res.status === 403) no(`${target.title}: not authorized -- reconnect the account (the calendar scope may be missing)`);
|
|
98
|
+
return res;
|
|
99
|
+
}
|
|
100
|
+
/* The events in a window, each expanded to its occurrences, briefed one per line. */
|
|
101
|
+
async function googleList(target, ctx, { from, to }) {
|
|
102
|
+
const s = icalUTC(from); const e = icalUTC(to);
|
|
103
|
+
const body = `<?xml version="1.0" encoding="utf-8"?><C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"><D:prop><D:getetag/><C:calendar-data><C:expand start="${s}" end="${e}"/></C:calendar-data></D:prop><C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"><C:time-range start="${s}" end="${e}"/></C:comp-filter></C:comp-filter></C:filter></C:calendar-query>`;
|
|
104
|
+
const res = await dav(target, ctx, 'REPORT', CALDAV(userOf(target)), { headers: { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8' }, body });
|
|
105
|
+
if (!res.ok) no(`${target.title}: the calendar could not be read (${res.status})`);
|
|
106
|
+
const xml = await res.text();
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const block of xml.split(/<[A-Za-z0-9]*:?response[\s>]/i).slice(1)) {
|
|
109
|
+
const href = /<[^>]*?:?href[^>]*>([\s\S]*?)<\/[^>]*?:?href>/i.exec(block)?.[1]?.trim();
|
|
110
|
+
const data = /<[^>]*?:?calendar-data[^>]*>([\s\S]*?)<\/[^>]*?:?calendar-data>/i.exec(block)?.[1];
|
|
111
|
+
if (!data) continue;
|
|
112
|
+
for (const piece of decodeXml(data).split(/BEGIN:VEVENT/i).slice(1)) {
|
|
113
|
+
const ev = fromVevent(piece);
|
|
114
|
+
if (ev.summary || ev.start) out.push({ id: href, ...ev });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
out.sort((x, y) => String(x.start).localeCompare(String(y.start)));
|
|
118
|
+
return out.slice(0, LIST_MAX).map((ev) => `${ev.id || '(no id)'}\n ${fmtICal(ev.start)} — ${ev.summary || '(no title)'}${ev.location ? ` @ ${ev.location}` : ''}`);
|
|
119
|
+
}
|
|
120
|
+
async function googleRead(target, ctx, id) {
|
|
121
|
+
const url = /^https?:\/\//.test(id) ? id : new URL(id, CALDAV(userOf(target))).href;
|
|
122
|
+
const res = await dav(target, ctx, 'GET', url, { headers: { 'Content-Type': 'text/calendar' } });
|
|
123
|
+
if (res.status === 404) no('no such event');
|
|
124
|
+
if (!res.ok) no(`${target.title}: that event could not be read (${res.status})`);
|
|
125
|
+
return (await res.text()).slice(0, READ_MAX);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const verbs = {
|
|
129
|
+
/* Events in a window, soonest first. By default from now on (the next
|
|
130
|
+
events); `from`/`to` (ISO dates or datetimes) move it into the past or
|
|
131
|
+
future, so the agent can look at any week or month, not only what's next. */
|
|
132
|
+
async 'calendar.list'(target, a, ctx) {
|
|
133
|
+
const provider = providerOf(target);
|
|
134
|
+
const when = (s) => { const d = s ? new Date(s) : null; return d && !Number.isNaN(d.getTime()) ? d.toISOString() : null; };
|
|
135
|
+
const from = when(a.from) ?? new Date().toISOString();
|
|
136
|
+
const to = when(a.to) ?? new Date(new Date(from).getTime() + 365 * 86_400_000).toISOString();
|
|
137
|
+
/* Google over CalDAV (the REST API its client cannot use); Microsoft over Graph. */
|
|
138
|
+
if (provider === 'google') {
|
|
139
|
+
const items = await googleList(target, ctx, { from, to });
|
|
140
|
+
const text = items.length ? items.join('\n') : 'No event in that range.';
|
|
141
|
+
return { text, size: Buffer.byteLength(text) };
|
|
142
|
+
}
|
|
143
|
+
const api = API[provider];
|
|
144
|
+
const res = await call(target, ctx, 'GET', api.list(LIST_MAX, { from, to }));
|
|
145
|
+
if (!res.ok) no(`${target.title}: ${res.status} ${await bodyError(res)}`.trim());
|
|
146
|
+
const items = api.items(await res.json());
|
|
147
|
+
const text = items.length ? items.map(api.brief).join('\n') : 'No event in that range.';
|
|
148
|
+
return { text, size: Buffer.byteLength(text) };
|
|
149
|
+
},
|
|
150
|
+
/* One event in full, by its id. */
|
|
151
|
+
async 'calendar.read'(target, a, ctx) {
|
|
152
|
+
const provider = providerOf(target);
|
|
153
|
+
if (provider === 'google') { const text = await googleRead(target, ctx, String(a.id)); return { text, size: Buffer.byteLength(text) }; }
|
|
154
|
+
const api = API[provider];
|
|
155
|
+
const res = await call(target, ctx, 'GET', api.event(String(a.id)));
|
|
156
|
+
if (res.status === 404) no('no such event');
|
|
157
|
+
if (!res.ok) no(`${target.title}: ${res.status} ${await bodyError(res)}`.trim());
|
|
158
|
+
const text = JSON.stringify(await res.json(), null, 1).slice(0, READ_MAX);
|
|
159
|
+
return { text, size: Buffer.byteLength(text) };
|
|
160
|
+
},
|
|
161
|
+
/* Create an event. An act: the start and end are ISO times (a bare date is all-day). */
|
|
162
|
+
async 'calendar.create'(target, a, ctx) {
|
|
163
|
+
const api = API[providerOf(target)];
|
|
164
|
+
if (!a.title) no('an event needs a title');
|
|
165
|
+
if (!a.start || !a.end) no('an event needs a start and an end (ISO times, e.g. 2030-01-02T09:00:00Z)');
|
|
166
|
+
const res = await call(target, ctx, 'POST', api.create, api.body({ title: a.title, start: a.start, end: a.end, where: a.where }));
|
|
167
|
+
if (!res.ok) no(`${target.title}: the event was not created (${res.status} ${await bodyError(res)})`.trim());
|
|
168
|
+
const made = await res.json().catch(() => ({}));
|
|
169
|
+
return { text: `Created: ${a.title} (${a.start}). Its id is ${made.id ?? '(unknown)'}.`, size: 0 };
|
|
170
|
+
},
|
|
171
|
+
/* Join a file to an event. An act. Microsoft takes the bytes inline (Graph
|
|
172
|
+
fileAttachment); a Google calendar attach is a Drive link, added once the
|
|
173
|
+
files connector is in -- for now it is Microsoft only. */
|
|
174
|
+
async 'calendar.attach'(target, a, ctx) {
|
|
175
|
+
const provider = providerOf(target);
|
|
176
|
+
if (provider !== 'microsoft') no(`${target.title}: joining a file to an event is Microsoft-only for now`);
|
|
177
|
+
if (!a.id) no('attach needs the event id (from the list)');
|
|
178
|
+
if (!a.attach) no('attach needs the file (its base64 bytes)');
|
|
179
|
+
const att = { '@odata.type': '#microsoft.graph.fileAttachment', name: String(a.attachName || 'attachment'), contentBytes: String(a.attach) };
|
|
180
|
+
const res = await call(target, ctx, 'POST', `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(String(a.id))}/attachments`, att);
|
|
181
|
+
if (!res.ok) no(`${target.title}: the file was not attached (${res.status} ${await bodyError(res)})`.trim());
|
|
182
|
+
return { text: `Attached ${a.attachName || 'the file'} to the event.`, size: 0 };
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
name: 'calendar',
|
|
188
|
+
verbs: Object.keys(verbs),
|
|
189
|
+
run(verb, target, a, ctx) {
|
|
190
|
+
if (!verbs[verb]) no(`${verb} is not done by this connector`);
|
|
191
|
+
return verbs[verb](target, a, ctx);
|
|
192
|
+
},
|
|
193
|
+
close() { tokens.clear(); },
|
|
194
|
+
shutdown() { tokens.clear(); },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/* THE CONTACTS CONNECTOR: a person's address book (Google or Microsoft) reached by
|
|
2
|
+
* the keeper with a token the host holds and the agent never sees. Microsoft is a
|
|
3
|
+
* REST read over Graph; Google is CardDAV (the scope the Thunderbird client carries),
|
|
4
|
+
* so the host speaks a small REPORT and parses the returned vCards. Read only for now
|
|
5
|
+
* (list, read) -- looking someone up is the common need; writing contacts comes later.
|
|
6
|
+
* Inspired by the MIT Google/Microsoft MCP servers, reimplemented host-side.
|
|
7
|
+
*
|
|
8
|
+
* target.config.provider 'google' or 'microsoft'
|
|
9
|
+
* target.config.user the email address (Google CardDAV needs it in the URL)
|
|
10
|
+
* target.secrets.refresh the NAME of the refresh token in the host's secret store
|
|
11
|
+
*/
|
|
12
|
+
import { no, Refused } from '../../server/verbs.mjs';
|
|
13
|
+
import { refreshAccess, thunderbird, PROVIDERS } from '../oauth.mjs';
|
|
14
|
+
|
|
15
|
+
const LIST_MAX = 50;
|
|
16
|
+
|
|
17
|
+
/* A vCard's useful fields. Lines fold with a leading space; unfold first. */
|
|
18
|
+
function fromVcard(text) {
|
|
19
|
+
const v = String(text).replace(/\r?\n[ \t]/g, '');
|
|
20
|
+
const one = (re) => { const m = re.exec(v); return m ? m[1].trim() : ''; };
|
|
21
|
+
const many = (re) => [...v.matchAll(re)].map((m) => m[1].trim()).filter(Boolean);
|
|
22
|
+
return { name: one(/^FN[^:\r\n]*:(.+)$/im), emails: many(/^EMAIL[^:\r\n]*:(.+)$/gim), tels: many(/^TEL[^:\r\n]*:(.+)$/gim) };
|
|
23
|
+
}
|
|
24
|
+
const brief = (id, c) => `${id}\n ${c.name || '(no name)'}${c.emails.length ? ` <${c.emails.join(', ')}>` : ''}${c.tels.length ? ` tel ${c.tels.join(' ')}` : ''}`;
|
|
25
|
+
const decodeXml = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
26
|
+
|
|
27
|
+
/* Microsoft returns JSON: normalise one contact to the same shape. */
|
|
28
|
+
const fromMs = (c) => ({ name: c.displayName || [c.givenName, c.surname].filter(Boolean).join(' '), emails: (c.emailAddresses ?? []).map((e) => e.address).filter(Boolean), tels: [c.mobilePhone, ...(c.businessPhones ?? []), ...(c.homePhones ?? [])].filter(Boolean) });
|
|
29
|
+
|
|
30
|
+
export function contacts({ fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
31
|
+
const tokens = new Map();
|
|
32
|
+
const providerOf = (target) => { const p = String(target.config?.provider ?? ''); if (!PROVIDERS[p] || !(p === 'google' || p === 'microsoft')) no(`${target.title}: set its provider to google or microsoft`); return p; };
|
|
33
|
+
const userOf = (target) => { const u = String(target.config?.user ?? '').trim(); if (!u) no(`${target.title}: set its email address`); return u; };
|
|
34
|
+
|
|
35
|
+
async function access(target, ctx) {
|
|
36
|
+
const provider = providerOf(target);
|
|
37
|
+
const cached = tokens.get(target.id);
|
|
38
|
+
if (cached && cached.exp > Date.now() + 60_000) return cached.value;
|
|
39
|
+
const refresh = await ctx.secret('refresh');
|
|
40
|
+
if (!refresh) no(`${target.title}: its refresh token is empty -- reconnect the account`);
|
|
41
|
+
const clientId = target.config?.clientId || thunderbird(provider).clientId;
|
|
42
|
+
const clientSecret = target.secrets?.secret ? await ctx.secret('secret') : thunderbird(provider).clientSecret;
|
|
43
|
+
let t;
|
|
44
|
+
try { t = await refreshAccess(provider, { clientId, clientSecret, refreshToken: refresh, fetch: fetchImpl }); }
|
|
45
|
+
catch (e) { if (e instanceof Refused) throw e; no(`${target.title}: the contacts must be reconnected (its authorisation was refused)`); }
|
|
46
|
+
tokens.set(target.id, { value: t.accessToken, exp: t.expiresAt });
|
|
47
|
+
return t.accessToken;
|
|
48
|
+
}
|
|
49
|
+
async function req(target, ctx, method, url, { headers = {}, body } = {}) {
|
|
50
|
+
const token = await access(target, ctx);
|
|
51
|
+
let res;
|
|
52
|
+
try { res = await fetchImpl(url, { method, headers: { Authorization: `Bearer ${token}`, ...headers }, ...(body !== undefined ? { body } : {}) }); }
|
|
53
|
+
catch (e) { if (e instanceof Refused) throw e; no(`${target.title}: cannot reach the address book`); }
|
|
54
|
+
if (res.status === 401 || res.status === 403) no(`${target.title}: not authorized -- reconnect the account (the contacts scope may be missing)`);
|
|
55
|
+
return res;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const CARDDAV = (user) => `https://www.googleapis.com/carddav/v1/principals/${encodeURIComponent(user)}/lists/default/`;
|
|
59
|
+
/* Listing all of a Google address book over CardDAV: an addressbook-query
|
|
60
|
+
needs a CARDDAV:filter (RFC 6352), and a match-all filter is awkward, so
|
|
61
|
+
enumerate the entries with a PROPFIND, then fetch their vCards with an
|
|
62
|
+
addressbook-multiget (both standard, and what Google accepts). */
|
|
63
|
+
const PROPFIND = '<?xml version="1.0" encoding="utf-8"?><D:propfind xmlns:D="DAV:"><D:prop><D:getetag/></D:prop></D:propfind>';
|
|
64
|
+
const hrefsIn = (xml) => [...xml.matchAll(/<[^>]*?:?href[^>]*>([\s\S]*?)<\/[^>]*?:?href>/gi)].map((m) => decodeXml(m[1].trim()));
|
|
65
|
+
|
|
66
|
+
async function googleList(target, ctx) {
|
|
67
|
+
const base = CARDDAV(userOf(target));
|
|
68
|
+
const basePath = (() => { try { return new URL(base).pathname.replace(/\/+$/, ''); } catch { return ''; } })();
|
|
69
|
+
const listed = await req(target, ctx, 'PROPFIND', base, { headers: { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8' }, body: PROPFIND });
|
|
70
|
+
if (!listed.ok) no(`${target.title}: the address book could not be read (${listed.status})`);
|
|
71
|
+
/* The collection lists itself and its contacts; keep the entries, whatever
|
|
72
|
+
they are named (Google uses an id, not a .vcf suffix). A contact is any
|
|
73
|
+
href under the collection that is not the collection itself. */
|
|
74
|
+
const hrefs = hrefsIn(await listed.text()).filter((h) => {
|
|
75
|
+
const clean = h.replace(/[?#].*$/, '');
|
|
76
|
+
if (!clean || clean.endsWith('/')) return false;
|
|
77
|
+
let p; try { p = new URL(h, base).pathname.replace(/\/+$/, ''); } catch { p = clean.replace(/\/+$/, ''); }
|
|
78
|
+
return p !== basePath;
|
|
79
|
+
}).slice(0, LIST_MAX);
|
|
80
|
+
if (!hrefs.length) return [];
|
|
81
|
+
const body = `<?xml version="1.0" encoding="utf-8"?><C:addressbook-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav"><D:prop><D:getetag/><C:address-data/></D:prop>${hrefs.map((h) => `<D:href>${h.replace(/&/g, '&').replace(/</g, '<')}</D:href>`).join('')}</C:addressbook-multiget>`;
|
|
82
|
+
const res = await req(target, ctx, 'REPORT', base, { headers: { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8' }, body });
|
|
83
|
+
if (!res.ok) no(`${target.title}: the address book could not be read (${res.status})`);
|
|
84
|
+
const xml = await res.text();
|
|
85
|
+
const out = [];
|
|
86
|
+
const blocks = xml.split(/<[A-Za-z0-9]*:?response[\s>]/i).slice(1);
|
|
87
|
+
for (const block of blocks) {
|
|
88
|
+
const href = /<[^>]*?:?href[^>]*>([\s\S]*?)<\/[^>]*?:?href>/i.exec(block)?.[1]?.trim();
|
|
89
|
+
const data = /<[^>]*?:?address-data[^>]*>([\s\S]*?)<\/[^>]*?:?address-data>/i.exec(block)?.[1];
|
|
90
|
+
if (!data) continue;
|
|
91
|
+
const card = fromVcard(decodeXml(data));
|
|
92
|
+
if (card.name || card.emails.length) out.push(brief(href || '(no id)', card));
|
|
93
|
+
if (out.length >= LIST_MAX) break;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const verbs = {
|
|
99
|
+
/* The address book, briefly: name, emails, phones. */
|
|
100
|
+
async 'contacts.list'(target, a, ctx) {
|
|
101
|
+
const provider = providerOf(target);
|
|
102
|
+
if (provider === 'google') { const items = await googleList(target, ctx); const text = items.length ? items.join('\n') : 'No contact.'; return { text, size: Buffer.byteLength(text) }; }
|
|
103
|
+
const res = await req(target, ctx, 'GET', 'https://graph.microsoft.com/v1.0/me/contacts?$top=50&$select=id,displayName,givenName,surname,emailAddresses,mobilePhone,businessPhones,homePhones');
|
|
104
|
+
if (!res.ok) no(`${target.title}: the address book could not be read (${res.status})`);
|
|
105
|
+
const items = ((await res.json()).value ?? []).map((c) => brief(c.id, fromMs(c)));
|
|
106
|
+
const text = items.length ? items.join('\n') : 'No contact.';
|
|
107
|
+
return { text, size: Buffer.byteLength(text) };
|
|
108
|
+
},
|
|
109
|
+
/* One contact in full, by its id (the Microsoft id, or the Google CardDAV href). */
|
|
110
|
+
async 'contacts.read'(target, a, ctx) {
|
|
111
|
+
const provider = providerOf(target);
|
|
112
|
+
const id = String(a.id ?? '').trim();
|
|
113
|
+
if (!id) no('read needs the contact id');
|
|
114
|
+
if (provider === 'google') {
|
|
115
|
+
const url = /^https?:\/\//.test(id) ? id : `https://www.googleapis.com${id.startsWith('/') ? '' : '/'}${id}`;
|
|
116
|
+
const res = await req(target, ctx, 'GET', url, { headers: { 'Content-Type': 'text/vcard' } });
|
|
117
|
+
if (res.status === 404) no('no such contact');
|
|
118
|
+
if (!res.ok) no(`${target.title}: that contact could not be read (${res.status})`);
|
|
119
|
+
const text = (await res.text()).slice(0, 64 * 1024);
|
|
120
|
+
return { text, size: Buffer.byteLength(text) };
|
|
121
|
+
}
|
|
122
|
+
const res = await req(target, ctx, 'GET', `https://graph.microsoft.com/v1.0/me/contacts/${encodeURIComponent(id)}`);
|
|
123
|
+
if (res.status === 404) no('no such contact');
|
|
124
|
+
if (!res.ok) no(`${target.title}: that contact could not be read (${res.status})`);
|
|
125
|
+
const text = JSON.stringify(await res.json(), null, 1).slice(0, 64 * 1024);
|
|
126
|
+
return { text, size: Buffer.byteLength(text) };
|
|
127
|
+
},
|
|
128
|
+
/* Set a contact's photo. An act. Microsoft takes the image bytes (Graph);
|
|
129
|
+
a Google photo means editing the vCard (coming with contacts writing) --
|
|
130
|
+
Microsoft only for now. */
|
|
131
|
+
async 'contacts.attach'(target, a, ctx) {
|
|
132
|
+
const provider = providerOf(target);
|
|
133
|
+
if (provider !== 'microsoft') no(`${target.title}: setting a contact photo is Microsoft-only for now`);
|
|
134
|
+
const id = String(a.id ?? '').trim();
|
|
135
|
+
if (!id) no('attach needs the contact id');
|
|
136
|
+
if (!a.attach) no('attach needs the photo (its base64 bytes)');
|
|
137
|
+
const res = await req(target, ctx, 'PUT', `https://graph.microsoft.com/v1.0/me/contacts/${encodeURIComponent(id)}/photo/$value`, { headers: { 'Content-Type': 'image/jpeg' }, body: Buffer.from(String(a.attach), 'base64') });
|
|
138
|
+
if (res.status === 404) no('no such contact');
|
|
139
|
+
if (!res.ok) no(`${target.title}: the photo was not set (${res.status})`);
|
|
140
|
+
return { text: `Set the photo on ${id}.`, size: 0 };
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
name: 'contacts',
|
|
146
|
+
verbs: Object.keys(verbs),
|
|
147
|
+
run(verb, target, a, ctx) {
|
|
148
|
+
if (!verbs[verb]) no(`${verb} is not done by this connector`);
|
|
149
|
+
return verbs[verb](target, a, ctx);
|
|
150
|
+
},
|
|
151
|
+
close() { tokens.clear(); },
|
|
152
|
+
shutdown() { tokens.clear(); },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/* MICROSOFT MAIL OVER GRAPH, for the mail connector's Microsoft path.
|
|
2
|
+
*
|
|
3
|
+
* The borrowed Microsoft client (Thunderbird's) takes Graph scopes: its token
|
|
4
|
+
* reads mail, calendar and contacts over graph.microsoft.com. So a Microsoft
|
|
5
|
+
* mailbox is
|
|
6
|
+
* reached here over Graph REST, not IMAP -- the same six verbs the IMAP path
|
|
7
|
+
* gives (list, read, search, move, flag, send), reimplemented host-side so the
|
|
8
|
+
* token never leaves this process and the Tether levels gate the acts.
|
|
9
|
+
*
|
|
10
|
+
* `token(target, ctx)` mints a Graph access token from the target's refresh
|
|
11
|
+
* token (the mail connector passes its own `access`). `fetch` is injected for
|
|
12
|
+
* tests. Message ids are Graph's own opaque ids (long strings), used as the
|
|
13
|
+
* "uid" for read/move/flag.
|
|
14
|
+
*/
|
|
15
|
+
import { no } from '../../server/verbs.mjs';
|
|
16
|
+
|
|
17
|
+
const GRAPH = 'https://graph.microsoft.com/v1.0';
|
|
18
|
+
const LIST_MAX = 30;
|
|
19
|
+
const READ_MAX = 512 * 1024;
|
|
20
|
+
/* Friendly folder words mapped to Graph's well-known names; anything else is
|
|
21
|
+
passed through (a folder's own id or display name). */
|
|
22
|
+
const FOLDER = { inbox: 'inbox', sent: 'sentitems', 'sent items': 'sentitems', drafts: 'drafts', draft: 'drafts', trash: 'deleteditems', deleted: 'deleteditems', 'deleted items': 'deleteditems', junk: 'junkemail', spam: 'junkemail', archive: 'archive' };
|
|
23
|
+
const folderOf = (name) => { const n = String(name ?? '').trim(); if (!n) return 'inbox'; return FOLDER[n.toLowerCase()] ?? n; };
|
|
24
|
+
|
|
25
|
+
const addrsOf = (list) => (list ?? []).map((r) => r?.emailAddress?.address).filter(Boolean).join(', ');
|
|
26
|
+
const stripHtml = (html) => String(html ?? '')
|
|
27
|
+
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
|
|
28
|
+
.replace(/<\s*br\s*\/?\s*>/gi, '\n').replace(/<\s*\/\s*(p|div|li|tr|h[1-6])\s*>/gi, '\n')
|
|
29
|
+
.replace(/<[^>]+>/g, '')
|
|
30
|
+
.replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>').replace(/"/gi, '"').replace(/'/gi, "'")
|
|
31
|
+
.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
32
|
+
|
|
33
|
+
export function graphMail({ fetch: fetchImpl = globalThis.fetch, token }) {
|
|
34
|
+
async function call(target, ctx, method, path, body) {
|
|
35
|
+
const access = await token(target, ctx);
|
|
36
|
+
const r = await fetchImpl(`${GRAPH}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: { authorization: `Bearer ${access}`, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
39
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
40
|
+
});
|
|
41
|
+
if (r.status === 401 || r.status === 403) no(`${target.title}: not authorized -- reconnect the account (the Microsoft mail scope may be missing)`);
|
|
42
|
+
if (!r.ok) { let m = ''; try { m = (await r.json())?.error?.message ?? ''; } catch { /* no json */ } no(`${target.title}: ${m || `the mail request failed (${r.status})`}`); }
|
|
43
|
+
if (r.status === 204) return null;
|
|
44
|
+
return r.json();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const brief = (m) => `#${m.id}\n ${m.receivedDateTime ?? ''}\n From: ${addrsOf([m.from])}\n Subject: ${m.subject ?? ''}`;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
async list(target, a, ctx) {
|
|
51
|
+
const folder = folderOf(a.mailbox);
|
|
52
|
+
const data = await call(target, ctx, 'GET', `/me/mailFolders/${encodeURIComponent(folder)}/messages?$top=${LIST_MAX}&$select=id,receivedDateTime,from,subject&$orderby=receivedDateTime%20desc`);
|
|
53
|
+
const items = (data?.value ?? []).map(brief);
|
|
54
|
+
const text = items.length ? items.join('\n') : 'No message.';
|
|
55
|
+
return { text, size: Buffer.byteLength(text) };
|
|
56
|
+
},
|
|
57
|
+
async read(target, a, ctx) {
|
|
58
|
+
const id = String(a.uid ?? '').trim();
|
|
59
|
+
if (!id) no('read needs the message id (from the list)');
|
|
60
|
+
const m = await call(target, ctx, 'GET', `/me/messages/${encodeURIComponent(id)}?$select=id,receivedDateTime,from,toRecipients,ccRecipients,subject,body,hasAttachments`);
|
|
61
|
+
const bodyText = m?.body?.contentType === 'html' ? stripHtml(m.body.content) : (m?.body?.content ?? '');
|
|
62
|
+
let att = '';
|
|
63
|
+
if (m?.hasAttachments) {
|
|
64
|
+
const at = await call(target, ctx, 'GET', `/me/messages/${encodeURIComponent(id)}/attachments?$select=name,contentType,size`);
|
|
65
|
+
att = (at?.value ?? []).map((x) => ` - ${x.name} (${x.contentType}, ${Math.round((x.size || 0) / 1024)} KB)`).join('\n');
|
|
66
|
+
}
|
|
67
|
+
const head = [`From: ${addrsOf([m.from])}`, `To: ${addrsOf(m.toRecipients)}`, m?.ccRecipients?.length ? `Cc: ${addrsOf(m.ccRecipients)}` : '', `Date: ${m.receivedDateTime ?? ''}`, `Subject: ${m.subject ?? ''}`].filter(Boolean).join('\n');
|
|
68
|
+
const text = `${head}\n\n${bodyText}${att ? `\n\nAttachments:\n${att}` : ''}`.slice(0, READ_MAX);
|
|
69
|
+
return { text, size: Buffer.byteLength(text) };
|
|
70
|
+
},
|
|
71
|
+
async search(target, a, ctx) {
|
|
72
|
+
const q = String(a.text ?? '').trim();
|
|
73
|
+
if (!q) no('search needs some text');
|
|
74
|
+
const data = await call(target, ctx, 'GET', `/me/messages?$search=${encodeURIComponent(`"${q}"`)}&$top=${LIST_MAX}&$select=id,receivedDateTime,from,subject`);
|
|
75
|
+
const items = (data?.value ?? []).map(brief);
|
|
76
|
+
const text = items.length ? items.join('\n') : 'Nothing found.';
|
|
77
|
+
return { text, size: Buffer.byteLength(text) };
|
|
78
|
+
},
|
|
79
|
+
async move(target, a, ctx) {
|
|
80
|
+
const id = String(a.uid ?? '').trim();
|
|
81
|
+
if (!id) no('move needs the message id');
|
|
82
|
+
if (!String(a.mailbox ?? '').trim()) no('move needs a destination folder');
|
|
83
|
+
await call(target, ctx, 'POST', `/me/messages/${encodeURIComponent(id)}/move`, { destinationId: folderOf(a.mailbox) });
|
|
84
|
+
return { text: `Moved ${id} to ${a.mailbox}.`, size: 0 };
|
|
85
|
+
},
|
|
86
|
+
async flag(target, a, ctx) {
|
|
87
|
+
const id = String(a.uid ?? '').trim();
|
|
88
|
+
if (!id) no('flag needs the message id');
|
|
89
|
+
const f = String(a.flag ?? '').toLowerCase().trim();
|
|
90
|
+
const body = f === 'read' ? { isRead: true } : f === 'unread' ? { isRead: false }
|
|
91
|
+
: f === 'flagged' ? { flag: { flagStatus: 'flagged' } } : f === 'unflagged' ? { flag: { flagStatus: 'notFlagged' } }
|
|
92
|
+
: no('flag is read, unread, flagged or unflagged');
|
|
93
|
+
await call(target, ctx, 'PATCH', `/me/messages/${encodeURIComponent(id)}`, body);
|
|
94
|
+
return { text: `Marked ${id} ${f}.`, size: 0 };
|
|
95
|
+
},
|
|
96
|
+
async send(target, a, ctx) {
|
|
97
|
+
const to = String(a.to ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
98
|
+
if (!to.length) no('send needs a recipient (to)');
|
|
99
|
+
const cc = String(a.cc ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
100
|
+
/* A file joined to the message: its bytes, already base64 (what Graph
|
|
101
|
+
wants), and a name. One attachment for now (the common need). */
|
|
102
|
+
const attach = String(a.attach ?? '');
|
|
103
|
+
const message = {
|
|
104
|
+
subject: String(a.subject ?? ''),
|
|
105
|
+
body: { contentType: 'text', content: String(a.body ?? '') },
|
|
106
|
+
toRecipients: to.map((address) => ({ emailAddress: { address } })),
|
|
107
|
+
...(cc.length ? { ccRecipients: cc.map((address) => ({ emailAddress: { address } })) } : {}),
|
|
108
|
+
...(attach ? { attachments: [{ '@odata.type': '#microsoft.graph.fileAttachment', name: String(a.attachName || 'attachment'), contentBytes: attach }] } : {}),
|
|
109
|
+
};
|
|
110
|
+
await call(target, ctx, 'POST', '/me/sendMail', { message, saveToSentItems: true });
|
|
111
|
+
return { text: `Sent to ${to.join(', ')}${attach ? ` with ${a.attachName || 'an attachment'}` : ''}.`, size: 0 };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|