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 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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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, '&amp;').replace(/</g, '&lt;')}</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(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;/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
+ }