nothumanallowed 4.1.0 → 6.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/package.json +10 -3
- package/src/cli.mjs +181 -5
- package/src/commands/autostart.mjs +342 -0
- package/src/commands/chat.mjs +14 -8
- package/src/commands/microsoft-auth.mjs +29 -0
- package/src/commands/ops.mjs +37 -0
- package/src/commands/plugin.mjs +481 -0
- package/src/commands/ui.mjs +28 -7
- package/src/commands/voice.mjs +845 -0
- package/src/config.mjs +61 -0
- package/src/constants.mjs +9 -1
- package/src/services/llm.mjs +22 -1
- package/src/services/mail-router.mjs +298 -0
- package/src/services/memory.mjs +627 -0
- package/src/services/message-responder.mjs +778 -0
- package/src/services/microsoft-calendar.mjs +319 -0
- package/src/services/microsoft-mail.mjs +308 -0
- package/src/services/microsoft-oauth.mjs +345 -0
- package/src/services/ops-daemon.mjs +620 -11
- package/src/services/ops-pipeline.mjs +7 -8
- package/src/services/token-store.mjs +41 -14
- package/src/services/tool-executor.mjs +392 -0
- package/src/services/web-ui.mjs +187 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outlook Calendar API wrapper via Microsoft Graph — zero dependencies.
|
|
3
|
+
* All calls via native fetch to Microsoft Graph REST API.
|
|
4
|
+
*
|
|
5
|
+
* Output format matches google-calendar.mjs parsed shape for unified consumption.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { getMicrosoftAccessToken } from './microsoft-oauth.mjs';
|
|
11
|
+
import { NHA_DIR } from '../constants.mjs';
|
|
12
|
+
|
|
13
|
+
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0/me';
|
|
14
|
+
const CAL_DIR = path.join(NHA_DIR, 'ops', 'calendar');
|
|
15
|
+
|
|
16
|
+
/** Authenticated fetch with auto-retry on 401 */
|
|
17
|
+
async function graphFetch(config, urlPath, options = {}) {
|
|
18
|
+
const token = await getMicrosoftAccessToken(config);
|
|
19
|
+
const url = urlPath.startsWith('http') ? urlPath : `${GRAPH_BASE}${urlPath}`;
|
|
20
|
+
|
|
21
|
+
let res = await fetch(url, {
|
|
22
|
+
...options,
|
|
23
|
+
headers: {
|
|
24
|
+
...options.headers,
|
|
25
|
+
'Authorization': `Bearer ${token}`,
|
|
26
|
+
'Prefer': 'outlook.timezone="' + Intl.DateTimeFormat().resolvedOptions().timeZone + '"',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (res.status === 401) {
|
|
31
|
+
const newToken = await getMicrosoftAccessToken(config);
|
|
32
|
+
res = await fetch(url, {
|
|
33
|
+
...options,
|
|
34
|
+
headers: {
|
|
35
|
+
...options.headers,
|
|
36
|
+
'Authorization': `Bearer ${newToken}`,
|
|
37
|
+
'Prefer': 'outlook.timezone="' + Intl.DateTimeFormat().resolvedOptions().timeZone + '"',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const err = await res.text();
|
|
44
|
+
throw new Error(`Microsoft Calendar API ${res.status}: ${err}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return res.json();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* List all calendars the user has access to.
|
|
52
|
+
*/
|
|
53
|
+
export async function listCalendars(config) {
|
|
54
|
+
const data = await graphFetch(config, '/calendars');
|
|
55
|
+
return (data.value || []).map(c => ({
|
|
56
|
+
id: c.id,
|
|
57
|
+
summary: c.name,
|
|
58
|
+
primary: c.isDefaultCalendar || false,
|
|
59
|
+
timeZone: c.owner?.address ? undefined : undefined, // Graph doesn't expose per-calendar TZ the same way
|
|
60
|
+
accessRole: c.canEdit ? 'writer' : 'reader',
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* List events for a date range.
|
|
66
|
+
* @param {object} config
|
|
67
|
+
* @param {string} calendarId — calendar ID or 'primary' for default
|
|
68
|
+
* @param {Date} timeMin
|
|
69
|
+
* @param {Date} timeMax
|
|
70
|
+
* @returns {Promise<Array>} parsed events
|
|
71
|
+
*/
|
|
72
|
+
export async function listEvents(config, calendarId = 'primary', timeMin, timeMax) {
|
|
73
|
+
const calPath = calendarId === 'primary'
|
|
74
|
+
? '/calendar/calendarView'
|
|
75
|
+
: `/calendars/${calendarId}/calendarView`;
|
|
76
|
+
|
|
77
|
+
const params = new URLSearchParams({
|
|
78
|
+
startDateTime: timeMin.toISOString(),
|
|
79
|
+
endDateTime: timeMax.toISOString(),
|
|
80
|
+
'$top': '50',
|
|
81
|
+
'$orderby': 'start/dateTime',
|
|
82
|
+
'$select': 'id,subject,body,location,start,end,isAllDay,attendees,organizer,onlineMeeting,webLink,showAs,responseStatus',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const data = await graphFetch(config, `${calPath}?${params}`);
|
|
86
|
+
const events = (data.value || []).map(parseEvent);
|
|
87
|
+
|
|
88
|
+
// Cache events
|
|
89
|
+
cacheEvents(timeMin, events);
|
|
90
|
+
return events;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get today's events from all calendars.
|
|
95
|
+
*/
|
|
96
|
+
export async function getTodayEvents(config) {
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
99
|
+
const endOfDay = new Date(startOfDay.getTime() + 86400000);
|
|
100
|
+
|
|
101
|
+
const calendars = await listCalendars(config);
|
|
102
|
+
const allEvents = [];
|
|
103
|
+
|
|
104
|
+
for (const cal of calendars) {
|
|
105
|
+
try {
|
|
106
|
+
const events = await listEvents(config, cal.id, startOfDay, endOfDay);
|
|
107
|
+
for (const e of events) {
|
|
108
|
+
e.calendarName = cal.summary;
|
|
109
|
+
allEvents.push(e);
|
|
110
|
+
}
|
|
111
|
+
} catch { /* skip failed calendars */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Sort by start time
|
|
115
|
+
allEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
|
116
|
+
return allEvents;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get events for a specific date.
|
|
121
|
+
*/
|
|
122
|
+
export async function getEventsForDate(config, date) {
|
|
123
|
+
const d = new Date(date);
|
|
124
|
+
const startOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
125
|
+
const endOfDay = new Date(startOfDay.getTime() + 86400000);
|
|
126
|
+
return listEvents(config, 'primary', startOfDay, endOfDay);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get upcoming events (next N hours).
|
|
131
|
+
* @param {number} hours — look-ahead hours (default 2)
|
|
132
|
+
*/
|
|
133
|
+
export async function getUpcomingEvents(config, hours = 2) {
|
|
134
|
+
const now = new Date();
|
|
135
|
+
const end = new Date(now.getTime() + hours * 3600000);
|
|
136
|
+
return listEvents(config, 'primary', now, end);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create a new event.
|
|
141
|
+
* @param {object} config
|
|
142
|
+
* @param {object} event — { summary, start, end, description, location, attendees }
|
|
143
|
+
* @param {string} calendarId
|
|
144
|
+
*/
|
|
145
|
+
export async function createEvent(config, event, calendarId = 'primary') {
|
|
146
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
147
|
+
|
|
148
|
+
const startStr = String(event.start);
|
|
149
|
+
const endStr = String(event.end);
|
|
150
|
+
|
|
151
|
+
// Normalize datetime — Microsoft Graph expects ISO format or { dateTime, timeZone }
|
|
152
|
+
const startDT = (startStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(startStr))
|
|
153
|
+
? new Date(startStr).toISOString().replace('Z', '')
|
|
154
|
+
: startStr;
|
|
155
|
+
const endDT = (endStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(endStr))
|
|
156
|
+
? new Date(endStr).toISOString().replace('Z', '')
|
|
157
|
+
: endStr;
|
|
158
|
+
|
|
159
|
+
const body = {
|
|
160
|
+
subject: event.summary,
|
|
161
|
+
body: {
|
|
162
|
+
contentType: 'Text',
|
|
163
|
+
content: event.description || '',
|
|
164
|
+
},
|
|
165
|
+
start: { dateTime: startDT, timeZone: tz },
|
|
166
|
+
end: { dateTime: endDT, timeZone: tz },
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (event.location) {
|
|
170
|
+
body.location = { displayName: event.location };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (event.attendees?.length) {
|
|
174
|
+
body.attendees = event.attendees.map(email => ({
|
|
175
|
+
emailAddress: { address: email },
|
|
176
|
+
type: 'required',
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const calPath = calendarId === 'primary'
|
|
181
|
+
? '/calendar/events'
|
|
182
|
+
: `/calendars/${calendarId}/events`;
|
|
183
|
+
|
|
184
|
+
return graphFetch(config, calPath, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
187
|
+
body: JSON.stringify(body),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Update an existing event (partial).
|
|
193
|
+
*/
|
|
194
|
+
export async function updateEvent(config, calendarId, eventId, patch) {
|
|
195
|
+
// Translate Google-style patch to Microsoft Graph format
|
|
196
|
+
const body = {};
|
|
197
|
+
|
|
198
|
+
if (patch.summary) body.subject = patch.summary;
|
|
199
|
+
if (patch.description) body.body = { contentType: 'Text', content: patch.description };
|
|
200
|
+
if (patch.location) body.location = { displayName: patch.location };
|
|
201
|
+
|
|
202
|
+
if (patch.start) {
|
|
203
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
204
|
+
if (typeof patch.start === 'object' && patch.start.dateTime) {
|
|
205
|
+
body.start = { dateTime: patch.start.dateTime.replace('Z', ''), timeZone: tz };
|
|
206
|
+
} else {
|
|
207
|
+
body.start = { dateTime: new Date(patch.start).toISOString().replace('Z', ''), timeZone: tz };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (patch.end) {
|
|
212
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
213
|
+
if (typeof patch.end === 'object' && patch.end.dateTime) {
|
|
214
|
+
body.end = { dateTime: patch.end.dateTime.replace('Z', ''), timeZone: tz };
|
|
215
|
+
} else {
|
|
216
|
+
body.end = { dateTime: new Date(patch.end).toISOString().replace('Z', ''), timeZone: tz };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const calPath = calendarId === 'primary'
|
|
221
|
+
? `/calendar/events/${eventId}`
|
|
222
|
+
: `/calendars/${calendarId}/events/${eventId}`;
|
|
223
|
+
|
|
224
|
+
return graphFetch(config, calPath, {
|
|
225
|
+
method: 'PATCH',
|
|
226
|
+
headers: { 'Content-Type': 'application/json' },
|
|
227
|
+
body: JSON.stringify(body),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Event Parser ───────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Parse Microsoft Graph event to unified format matching Google Calendar output.
|
|
235
|
+
*/
|
|
236
|
+
function parseEvent(raw) {
|
|
237
|
+
const isAllDay = raw.isAllDay || false;
|
|
238
|
+
|
|
239
|
+
// Microsoft returns { dateTime, timeZone } objects for start/end
|
|
240
|
+
let start = '';
|
|
241
|
+
let end = '';
|
|
242
|
+
|
|
243
|
+
if (isAllDay) {
|
|
244
|
+
// All-day events: use date portion only (YYYY-MM-DD)
|
|
245
|
+
start = raw.start?.dateTime?.split('T')[0] || '';
|
|
246
|
+
end = raw.end?.dateTime?.split('T')[0] || '';
|
|
247
|
+
} else {
|
|
248
|
+
// Timed events: use full ISO datetime
|
|
249
|
+
start = raw.start?.dateTime || '';
|
|
250
|
+
end = raw.end?.dateTime || '';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Build online meeting link (Teams or other)
|
|
254
|
+
const meetingLink = raw.onlineMeeting?.joinUrl || '';
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
id: raw.id,
|
|
258
|
+
summary: raw.subject || '(no title)',
|
|
259
|
+
description: raw.body?.content
|
|
260
|
+
? raw.body.content
|
|
261
|
+
.replace(/<[^>]+>/g, ' ')
|
|
262
|
+
.replace(/ /g, ' ')
|
|
263
|
+
.replace(/\s+/g, ' ')
|
|
264
|
+
.trim()
|
|
265
|
+
.slice(0, 2000)
|
|
266
|
+
: '',
|
|
267
|
+
location: raw.location?.displayName || '',
|
|
268
|
+
start,
|
|
269
|
+
end,
|
|
270
|
+
isAllDay,
|
|
271
|
+
status: raw.showAs || 'busy',
|
|
272
|
+
attendees: (raw.attendees || []).map(a => ({
|
|
273
|
+
email: a.emailAddress?.address || '',
|
|
274
|
+
name: a.emailAddress?.name || '',
|
|
275
|
+
responseStatus: mapResponseStatus(a.status?.response),
|
|
276
|
+
self: false,
|
|
277
|
+
})),
|
|
278
|
+
organizer: raw.organizer?.emailAddress?.address || '',
|
|
279
|
+
hangoutLink: meetingLink, // unified field for any online meeting link
|
|
280
|
+
htmlLink: raw.webLink || '',
|
|
281
|
+
calendarName: '',
|
|
282
|
+
provider: 'microsoft',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Map Microsoft response status to Google-compatible values.
|
|
288
|
+
*/
|
|
289
|
+
function mapResponseStatus(msStatus) {
|
|
290
|
+
const map = {
|
|
291
|
+
none: 'needsAction',
|
|
292
|
+
organizer: 'accepted',
|
|
293
|
+
tentativelyAccepted: 'tentative',
|
|
294
|
+
accepted: 'accepted',
|
|
295
|
+
declined: 'declined',
|
|
296
|
+
notResponded: 'needsAction',
|
|
297
|
+
};
|
|
298
|
+
return map[msStatus] || 'needsAction';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Local Cache ────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
function cacheEvents(date, events) {
|
|
304
|
+
fs.mkdirSync(CAL_DIR, { recursive: true });
|
|
305
|
+
const dateStr = date instanceof Date ? date.toISOString().split('T')[0] : String(date);
|
|
306
|
+
const file = path.join(CAL_DIR, `ms-events-${dateStr}.json`);
|
|
307
|
+
fs.writeFileSync(file, JSON.stringify(events, null, 2), { mode: 0o600 });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Load cached events for a date.
|
|
312
|
+
*/
|
|
313
|
+
export function loadCachedEvents(date) {
|
|
314
|
+
const dateStr = date instanceof Date ? date.toISOString().split('T')[0] : String(date);
|
|
315
|
+
const file = path.join(CAL_DIR, `ms-events-${dateStr}.json`);
|
|
316
|
+
if (!fs.existsSync(file)) return null;
|
|
317
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf-8')); }
|
|
318
|
+
catch { return null; }
|
|
319
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outlook Mail API wrapper via Microsoft Graph — zero dependencies.
|
|
3
|
+
* All calls via native fetch to Microsoft Graph REST API.
|
|
4
|
+
* Auto-refreshes tokens on 401.
|
|
5
|
+
*
|
|
6
|
+
* Output format matches google-gmail.mjs parsed shape for unified consumption.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { getMicrosoftAccessToken } from './microsoft-oauth.mjs';
|
|
12
|
+
import { NHA_DIR } from '../constants.mjs';
|
|
13
|
+
|
|
14
|
+
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0/me';
|
|
15
|
+
const MAIL_DIR = path.join(NHA_DIR, 'ops', 'mail');
|
|
16
|
+
const INBOX_DIR = path.join(MAIL_DIR, 'inbox');
|
|
17
|
+
|
|
18
|
+
/** Authenticated fetch with auto-retry on 401 */
|
|
19
|
+
async function graphFetch(config, urlPath, options = {}) {
|
|
20
|
+
const token = await getMicrosoftAccessToken(config);
|
|
21
|
+
const url = urlPath.startsWith('http') ? urlPath : `${GRAPH_BASE}${urlPath}`;
|
|
22
|
+
|
|
23
|
+
let res = await fetch(url, {
|
|
24
|
+
...options,
|
|
25
|
+
headers: {
|
|
26
|
+
...options.headers,
|
|
27
|
+
'Authorization': `Bearer ${token}`,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Retry once on 401 (token may have expired between check and request)
|
|
32
|
+
if (res.status === 401) {
|
|
33
|
+
const newToken = await getMicrosoftAccessToken(config);
|
|
34
|
+
res = await fetch(url, {
|
|
35
|
+
...options,
|
|
36
|
+
headers: {
|
|
37
|
+
...options.headers,
|
|
38
|
+
'Authorization': `Bearer ${newToken}`,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const err = await res.text();
|
|
45
|
+
throw new Error(`Microsoft Graph API ${res.status}: ${err}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return res.json();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* List messages matching a query.
|
|
53
|
+
* @param {object} config
|
|
54
|
+
* @param {string} query — Microsoft Graph $search or $filter compatible string.
|
|
55
|
+
* Supports natural language search (e.g., "from:boss@company.com subject:report").
|
|
56
|
+
* @param {number} maxResults — max messages (default 20)
|
|
57
|
+
* @returns {Promise<Array<object>>} parsed messages
|
|
58
|
+
*/
|
|
59
|
+
export async function listMessages(config, query = '', maxResults = 20) {
|
|
60
|
+
const params = new URLSearchParams({
|
|
61
|
+
'$top': String(maxResults),
|
|
62
|
+
'$orderby': 'receivedDateTime desc',
|
|
63
|
+
'$select': 'id,conversationId,from,toRecipients,ccRecipients,subject,receivedDateTime,bodyPreview,body,isRead,importance,flag,webLink,hasAttachments',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Microsoft Graph $search uses KQL (Keyword Query Language)
|
|
67
|
+
if (query) {
|
|
68
|
+
params.set('$search', `"${query}"`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const data = await graphFetch(config, `/messages?${params}`);
|
|
72
|
+
return (data.value || []).map(parseMessage);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get a full message by ID.
|
|
77
|
+
* @param {object} config
|
|
78
|
+
* @param {string} messageId
|
|
79
|
+
* @returns {Promise<object>} parsed message
|
|
80
|
+
*/
|
|
81
|
+
export async function getMessage(config, messageId) {
|
|
82
|
+
const data = await graphFetch(config, `/messages/${messageId}`);
|
|
83
|
+
return parseMessage(data);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get unread important emails (for daily planner).
|
|
88
|
+
* @param {object} config
|
|
89
|
+
* @param {number} maxResults
|
|
90
|
+
* @returns {Promise<Array>} parsed messages
|
|
91
|
+
*/
|
|
92
|
+
export async function getUnreadImportant(config, maxResults = 30) {
|
|
93
|
+
const params = new URLSearchParams({
|
|
94
|
+
'$top': String(maxResults),
|
|
95
|
+
'$orderby': 'receivedDateTime desc',
|
|
96
|
+
'$filter': 'isRead eq false',
|
|
97
|
+
'$select': 'id,conversationId,from,toRecipients,ccRecipients,subject,receivedDateTime,bodyPreview,body,isRead,importance,flag,webLink,hasAttachments',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const data = await graphFetch(config, `/messages?${params}`);
|
|
101
|
+
const messages = (data.value || []).map(parseMessage);
|
|
102
|
+
|
|
103
|
+
// Cache messages locally
|
|
104
|
+
cacheMessages(messages);
|
|
105
|
+
return messages;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get emails from today (read + unread).
|
|
110
|
+
*/
|
|
111
|
+
export async function getTodayEmails(config, maxResults = 50) {
|
|
112
|
+
const today = new Date();
|
|
113
|
+
today.setHours(0, 0, 0, 0);
|
|
114
|
+
const todayISO = today.toISOString();
|
|
115
|
+
|
|
116
|
+
const params = new URLSearchParams({
|
|
117
|
+
'$top': String(maxResults),
|
|
118
|
+
'$orderby': 'receivedDateTime desc',
|
|
119
|
+
'$filter': `receivedDateTime ge ${todayISO}`,
|
|
120
|
+
'$select': 'id,conversationId,from,toRecipients,ccRecipients,subject,receivedDateTime,bodyPreview,body,isRead,importance,flag,webLink,hasAttachments',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const data = await graphFetch(config, `/messages?${params}`);
|
|
124
|
+
const messages = (data.value || []).map(parseMessage);
|
|
125
|
+
|
|
126
|
+
cacheMessages(messages);
|
|
127
|
+
return messages;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Send an email via Microsoft Graph.
|
|
132
|
+
* @param {object} config
|
|
133
|
+
* @param {string} to — recipient email
|
|
134
|
+
* @param {string} subject
|
|
135
|
+
* @param {string} body — plain text body
|
|
136
|
+
* @param {object} opts — { cc, bcc, replyToMessageId, threadId }
|
|
137
|
+
*/
|
|
138
|
+
export async function sendEmail(config, to, subject, body, opts = {}) {
|
|
139
|
+
if (opts.replyToMessageId) {
|
|
140
|
+
// Reply to existing message
|
|
141
|
+
const replyBody = {
|
|
142
|
+
message: {
|
|
143
|
+
toRecipients: [{ emailAddress: { address: to } }],
|
|
144
|
+
},
|
|
145
|
+
comment: body,
|
|
146
|
+
};
|
|
147
|
+
return graphFetch(config, `/messages/${opts.replyToMessageId}/reply`, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify(replyBody),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const message = {
|
|
155
|
+
message: {
|
|
156
|
+
subject,
|
|
157
|
+
body: {
|
|
158
|
+
contentType: 'Text',
|
|
159
|
+
content: body,
|
|
160
|
+
},
|
|
161
|
+
toRecipients: to.split(',').map(addr => ({
|
|
162
|
+
emailAddress: { address: addr.trim() },
|
|
163
|
+
})),
|
|
164
|
+
},
|
|
165
|
+
saveToSentItems: true,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (opts.cc) {
|
|
169
|
+
message.message.ccRecipients = opts.cc.split(',').map(addr => ({
|
|
170
|
+
emailAddress: { address: addr.trim() },
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (opts.bcc) {
|
|
175
|
+
message.message.bccRecipients = opts.bcc.split(',').map(addr => ({
|
|
176
|
+
emailAddress: { address: addr.trim() },
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return graphFetch(config, '/sendMail', {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: { 'Content-Type': 'application/json' },
|
|
183
|
+
body: JSON.stringify(message),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create a draft email.
|
|
189
|
+
*/
|
|
190
|
+
export async function createDraft(config, to, subject, body) {
|
|
191
|
+
const draft = {
|
|
192
|
+
subject,
|
|
193
|
+
body: {
|
|
194
|
+
contentType: 'Text',
|
|
195
|
+
content: body,
|
|
196
|
+
},
|
|
197
|
+
toRecipients: to.split(',').map(addr => ({
|
|
198
|
+
emailAddress: { address: addr.trim() },
|
|
199
|
+
})),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return graphFetch(config, '/messages', {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
205
|
+
body: JSON.stringify(draft),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get user's email profile.
|
|
211
|
+
*/
|
|
212
|
+
export async function getProfile(config) {
|
|
213
|
+
const data = await graphFetch(config, '');
|
|
214
|
+
return {
|
|
215
|
+
emailAddress: data.mail || data.userPrincipalName || '',
|
|
216
|
+
displayName: data.displayName || '',
|
|
217
|
+
id: data.id || '',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Message Parser ─────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Parse Microsoft Graph message to unified format matching Gmail parser output.
|
|
225
|
+
*/
|
|
226
|
+
function parseMessage(raw) {
|
|
227
|
+
const from = raw.from?.emailAddress
|
|
228
|
+
? `${raw.from.emailAddress.name || ''} <${raw.from.emailAddress.address}>`
|
|
229
|
+
: '';
|
|
230
|
+
|
|
231
|
+
const to = (raw.toRecipients || [])
|
|
232
|
+
.map(r => `${r.emailAddress?.name || ''} <${r.emailAddress?.address}>`)
|
|
233
|
+
.join(', ');
|
|
234
|
+
|
|
235
|
+
// Extract plain text body (Graph returns HTML by default)
|
|
236
|
+
let body = '';
|
|
237
|
+
if (raw.body?.content) {
|
|
238
|
+
if (raw.body.contentType === 'text') {
|
|
239
|
+
body = raw.body.content;
|
|
240
|
+
} else {
|
|
241
|
+
// Strip HTML tags for plain text representation
|
|
242
|
+
body = raw.body.content
|
|
243
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
244
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
245
|
+
.replace(/<[^>]+>/g, ' ')
|
|
246
|
+
.replace(/ /g, ' ')
|
|
247
|
+
.replace(/&/g, '&')
|
|
248
|
+
.replace(/</g, '<')
|
|
249
|
+
.replace(/>/g, '>')
|
|
250
|
+
.replace(/"/g, '"')
|
|
251
|
+
.replace(/'/g, "'")
|
|
252
|
+
.replace(/\s+/g, ' ')
|
|
253
|
+
.trim();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Extract URLs from body
|
|
258
|
+
const urls = (body.match(/https?:\/\/[^\s<>"']+/g) || []).slice(0, 10);
|
|
259
|
+
|
|
260
|
+
// Map Microsoft importance to labels
|
|
261
|
+
const labels = [];
|
|
262
|
+
if (!raw.isRead) labels.push('UNREAD');
|
|
263
|
+
if (raw.importance === 'high') labels.push('IMPORTANT');
|
|
264
|
+
if (raw.flag?.flagStatus === 'flagged') labels.push('FLAGGED');
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
id: raw.id,
|
|
268
|
+
threadId: raw.conversationId || raw.id,
|
|
269
|
+
from,
|
|
270
|
+
to,
|
|
271
|
+
subject: raw.subject || '(no subject)',
|
|
272
|
+
date: raw.receivedDateTime || '',
|
|
273
|
+
snippet: (raw.bodyPreview || '').slice(0, 200),
|
|
274
|
+
body: body.slice(0, 5000), // cap for LLM context
|
|
275
|
+
urls,
|
|
276
|
+
labels,
|
|
277
|
+
isUnread: !raw.isRead,
|
|
278
|
+
isImportant: raw.importance === 'high',
|
|
279
|
+
sizeEstimate: 0, // Graph API doesn't expose this directly
|
|
280
|
+
provider: 'microsoft',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Local Cache ────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
function cacheMessages(messages) {
|
|
287
|
+
fs.mkdirSync(INBOX_DIR, { recursive: true });
|
|
288
|
+
for (const msg of messages) {
|
|
289
|
+
const safeId = msg.id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120);
|
|
290
|
+
const file = path.join(INBOX_DIR, `ms_${safeId}.json`);
|
|
291
|
+
fs.writeFileSync(file, JSON.stringify(msg, null, 2), { mode: 0o600 });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Load cached messages from disk.
|
|
297
|
+
* @returns {Array} cached messages
|
|
298
|
+
*/
|
|
299
|
+
export function loadCachedMessages() {
|
|
300
|
+
if (!fs.existsSync(INBOX_DIR)) return [];
|
|
301
|
+
return fs.readdirSync(INBOX_DIR)
|
|
302
|
+
.filter(f => f.startsWith('ms_') && f.endsWith('.json'))
|
|
303
|
+
.map(f => {
|
|
304
|
+
try { return JSON.parse(fs.readFileSync(path.join(INBOX_DIR, f), 'utf-8')); }
|
|
305
|
+
catch { return null; }
|
|
306
|
+
})
|
|
307
|
+
.filter(Boolean);
|
|
308
|
+
}
|