/** * Microsoft Graph API Client * Client credentials flow for application permissions * Reports.Read.All, User.Read.All, Calendars.Read */ export interface GraphUser { id: string; displayName: string | null; mail: string | null; userPrincipalName: string | null; jobTitle: string | null; department: string | null; accountEnabled: boolean | null; } export interface TeamsActivityRow { userPrincipalName: string; lastActivityDate: string; teamChatMessageCount: number; privateChatMessageCount: number; callCount: number; meetingCount: number; meetingsOrganizedCount: number; meetingsAttendedCount: number; audioDurationSeconds: number; } export interface EmailActivityRow { userPrincipalName: string; lastActivityDate: string; sendCount: number; receiveCount: number; readCount: number; } export interface UserMessage { createdDateTime: string; fromUserId: string | null; } export interface CalendarEvent { id: string; subject: string; start: { dateTime: string; timeZone: string }; end: { dateTime: string; timeZone: string }; attendees: Array<{ emailAddress: { address: string; name: string }; type: string; }>; isOnlineMeeting: boolean; isCancelled: boolean; } export interface MsGraphClientConfig { tenantId: string; clientId: string; clientSecret: string; } export class MsGraphClient { private config: MsGraphClientConfig; private accessToken: string | null = null; private tokenExpiry: number = 0; constructor(config: MsGraphClientConfig) { this.config = config; } private async getToken(): Promise { if (this.accessToken && Date.now() < this.tokenExpiry - 60000) { return this.accessToken; } const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`; const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: this.config.clientId, client_secret: this.config.clientSecret, scope: 'https://graph.microsoft.com/.default', }); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); if (!res.ok) { const text = await res.text(); throw new Error(`Graph token request failed: ${res.status} ${text}`); } const data = await res.json(); this.accessToken = data.access_token; this.tokenExpiry = Date.now() + data.expires_in * 1000; return this.accessToken!; } private async fetchJson(path: string, retryCount = 0): Promise { const token = await this.getToken(); const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', }, }); // Retry on 429 rate limit — respect Retry-After header (minimum 30s) if (res.status === 429 && retryCount < 4) { const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10)); await new Promise(r => setTimeout(r, retryAfter * 1000)); return this.fetchJson(path, retryCount + 1); } if (!res.ok) { const text = await res.text(); throw new Error(`Graph API error ${res.status} for ${path}: ${text}`); } return res.json(); } private async fetchCsv(path: string): Promise { const token = await this.getToken(); const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: 'text/csv', }, }); if (!res.ok) { const text = await res.text(); throw new Error(`Graph API error ${res.status} for ${path}: ${text}`); } return res.text(); } /** * Parse CSV — handles BOM prefix on first header column */ private parseCsv(csv: string): Record[] { const lines = csv.split('\n').filter(l => l.trim()); if (lines.length < 2) return []; // Strip BOM from first header if present const rawHeaders = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, '').replace(/^\uFEFF/, '')); const rows: Record[] = []; for (let i = 1; i < lines.length; i++) { const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, '')); const row: Record = {}; rawHeaders.forEach((h, idx) => { row[h] = values[idx] ?? ''; }); rows.push(row); } return rows; } private parseInt0(v: string): number { const n = parseInt(v); return isNaN(n) ? 0 : n; } /** * Get all users in the tenant (paginated) */ async getUsers(): Promise { const users: GraphUser[] = []; let url: string | null = '/users?$select=id,displayName,mail,userPrincipalName,jobTitle,department,accountEnabled&$top=999'; while (url) { const data: { value: GraphUser[]; '@odata.nextLink'?: string } = await this.fetchJson(url); users.push(...data.value); if (data['@odata.nextLink']) { url = data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', ''); } else { url = null; } } return users; } /** * Get the tenant's verified email domains (used to identify external attendees) */ async getOrganizationDomains(): Promise { try { const data = await this.fetchJson<{ value: Array<{ verifiedDomains: Array<{ name: string; isDefault: boolean }> }>; }>('/organization?$select=verifiedDomains'); return (data.value[0]?.verifiedDomains ?? []).map(d => d.name.toLowerCase()); } catch { return []; } } /** * Get Teams user activity report — includes audio duration in seconds * period: 'D7' | 'D30' | 'D90' */ async getTeamsActivity(period: string): Promise { const csv = await this.fetchCsv(`/reports/getTeamsUserActivityUserDetail(period='${period}')`); const rows = this.parseCsv(csv); return rows .filter(r => r['User Principal Name']) .map(r => ({ userPrincipalName: r['User Principal Name'] || '', lastActivityDate: r['Last Activity Date'] || '', teamChatMessageCount: this.parseInt0(r['Team Chat Message Count']), privateChatMessageCount: this.parseInt0(r['Private Chat Message Count']), callCount: this.parseInt0(r['Call Count']), meetingCount: this.parseInt0(r['Meeting Count']), meetingsOrganizedCount: this.parseInt0(r['Meetings Organized Count']), meetingsAttendedCount: this.parseInt0(r['Meetings Attended Count']), // "Audio Duration In Seconds" is the pre-computed seconds column audioDurationSeconds: this.parseInt0(r['Audio Duration In Seconds']), })); } /** * Get Email activity report * period: 'D7' | 'D30' | 'D90' */ async getEmailActivity(period: string): Promise { const csv = await this.fetchCsv(`/reports/getEmailActivityUserDetail(period='${period}')`); const rows = this.parseCsv(csv); return rows .filter(r => r['User Principal Name']) .map(r => ({ userPrincipalName: r['User Principal Name'] || '', lastActivityDate: r['Last Activity Date'] || '', sendCount: this.parseInt0(r['Send Count']), receiveCount: this.parseInt0(r['Receive Count']), readCount: this.parseInt0(r['Read Count']), })); } /** * Get messages sent by a user in a date range, across all their chats. * Requires Chat.Read.All application permission. * * Strategy (based on Graph API docs): * 1. GET /users/{id}/chats with $expand=lastMessagePreview to find recently active chats. * Stop paging when the chat's last message preview is older than startDate. * 2. For each recent chat, GET /chats/{id}/messages with: * $filter=lastModifiedDateTime gt {start} and lastModifiedDateTime lt {end} * $orderby=lastModifiedDateTime desc (newest first) * * Returns only messages sent by this user (from.user.id match). */ async getUserMessages( userId: string, startDate: Date, endDate: Date ): Promise { const messages: UserMessage[] = []; const startIso = startDate.toISOString().slice(0, 19) + 'Z'; const endIso = endDate.toISOString().slice(0, 19) + 'Z'; const startMs = startDate.getTime(); // Step 1: get chats sorted by most recent activity, stop when too old const chatIds: string[] = []; let chatUrl: string | null = `/users/${encodeURIComponent(userId)}/chats` + `?$expand=lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc&$top=50`; while (chatUrl && chatIds.length < 200) { const data: { value: Array<{ id: string; lastMessagePreview?: { createdDateTime?: string } }>; '@odata.nextLink'?: string; } = await this.fetchJson(chatUrl); let hitOldChat = false; for (const chat of data.value) { const lastMsgTime = chat.lastMessagePreview?.createdDateTime ? new Date(chat.lastMessagePreview.createdDateTime).getTime() : null; if (lastMsgTime !== null && lastMsgTime < startMs) { hitOldChat = true; break; } chatIds.push(chat.id); } chatUrl = hitOldChat ? null : data['@odata.nextLink'] ? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '') : null; } // Step 2: for each recent chat, get messages in the date range for (const chatId of chatIds) { let msgUrl: string | null = `/chats/${encodeURIComponent(chatId)}/messages` + `?$filter=lastModifiedDateTime gt ${startIso} and lastModifiedDateTime lt ${endIso}` + `&$orderby=lastModifiedDateTime desc&$top=50`; let pageCount = 0; try { while (msgUrl && pageCount < 20) { const data: { value: Array<{ createdDateTime: string; from?: { user?: { id: string } } }>; '@odata.nextLink'?: string; } = await this.fetchJson(msgUrl); pageCount++; for (const msg of data.value) { if (msg.from?.user?.id !== userId) continue; messages.push({ createdDateTime: msg.createdDateTime, fromUserId: userId }); } msgUrl = data['@odata.nextLink'] ? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '') : null; } } catch { // Skip inaccessible chats (403 on meeting threads, 429 exhausted, etc.) } } return messages; } /** * POST /users/{user}/messages — search by sender and/or subject in a date range. * Requires Mail.ReadWrite application permission. */ async searchMailboxMessages(options: { userEmail: string; fromAddress?: string; subject?: string; maxResults?: number; }): Promise<{ id: string; subject: string; from: string; receivedDateTime: string; isRead: boolean }[]> { // Exchange Online requires KQL $search for nested properties like from/emailAddress/address const kqlParts: string[] = []; if (options.fromAddress) kqlParts.push(`from:${options.fromAddress}`); if (options.subject) kqlParts.push(`subject:${options.subject}`); if (!kqlParts.length) throw new Error('At least fromAddress or subject required'); const top = Math.min(options.maxResults ?? 50, 100); const searchVal = `"${kqlParts.join(' ')}"`; const qs = `$search=${encodeURIComponent(searchVal)}&$select=id,subject,from,receivedDateTime,isRead&$top=${top}`; const url = `/users/${encodeURIComponent(options.userEmail)}/messages?${qs}`; const token = await this.getToken(); const res = await fetch(`https://graph.microsoft.com/v1.0${url}`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ConsistencyLevel: 'eventual', }, }); if (!res.ok) { const text = await res.text(); throw new Error(`Graph API error ${res.status}: ${text}`); } const data = await res.json(); return (data.value ?? []).map((m: any) => ({ id: m.id, subject: m.subject ?? '', from: m.from?.emailAddress?.address ?? '', receivedDateTime: m.receivedDateTime ?? '', isRead: m.isRead ?? false, })); } /** * DELETE /users/{user}/messages/{messageId} * Permanently deletes a message. Requires Mail.ReadWrite application permission. */ async deleteMailboxMessage(userEmail: string, messageId: string): Promise { const token = await this.getToken(); const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/messages/${encodeURIComponent(messageId)}`; const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 204) return; if (res.status === 404) return; // already gone const text = await res.text(); throw new Error(`Graph delete failed ${res.status}: ${text}`); } /** * Move a message to Deleted Items (soft delete — recoverable). * Requires Mail.ReadWrite application permission. */ async moveToDeletedItems(userEmail: string, messageId: string): Promise { const token = await this.getToken(); const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/messages/${encodeURIComponent(messageId)}/move`; const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ destinationId: 'deleteditems' }), }); if (res.ok) return; if (res.status === 404) return; const text = await res.text(); throw new Error(`Graph move failed ${res.status}: ${text}`); } /** * Fetch the raw photo bytes for a Microsoft Graph user. * Returns { bytes, contentType } on 200, null when the user has no photo (404), * and throws on any other non-2xx response so the caller can map to 502/503. * * Does NOT reuse fetchJson (which is JSON-only); issues its own fetch for binary data. * No retry logic — photo fetches are best-effort per D-26. */ async getUserPhotoBytes(userId: string): Promise<{ bytes: ArrayBuffer; contentType: string } | null> { const token = await this.getToken(); const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/photo/$value`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 404) { return null; } if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Graph photo error ${res.status} for user ${userId}: ${text}`); } const bytes = await res.arrayBuffer(); const contentType = res.headers.get('content-type') ?? 'image/jpeg'; return { bytes, contentType }; } /** * Get calendar events for a user in a date range (paginated). * Returns empty array and logs if the mailbox is not Exchange Online (graceful degradation). */ async getUserCalendarEvents( userId: string, startDate: Date, endDate: Date ): Promise { const start = startDate.toISOString(); const end = endDate.toISOString(); const events: CalendarEvent[] = []; let url: string | null = `/users/${encodeURIComponent(userId)}/calendarView` + `?startDateTime=${start}&endDateTime=${end}` + `&$select=id,subject,start,end,attendees,isOnlineMeeting,isCancelled` + `&$top=100`; while (url) { try { const data: { value: CalendarEvent[]; '@odata.nextLink'?: string } = await this.fetchJson(url); events.push(...data.value.filter(e => !e.isCancelled)); url = data['@odata.nextLink'] ? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '') : null; } catch (err) { const msg = err instanceof Error ? err.message : String(err); // Guests and on-prem mailboxes don't support REST API — skip silently if (msg.includes('MailboxNotEnabledForRESTAPI') || msg.includes('404')) { break; } throw err; } } return events; } }