- New public method fetches binary photo from Graph /users/{id}/photo/$value
- Returns { bytes, contentType } on 200, null on 404 (no photo)
- Throws on other non-2xx for upstream caller to map to 502/503
- Reuses getToken() OAuth2 cache; no retry (best-effort per D-26)
- Existing methods (getToken, fetchJson, getUsers, etc.) untouched
479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<T>(path: string, retryCount = 0): Promise<T> {
|
|
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<string> {
|
|
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<string, string>[] {
|
|
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<string, string>[] = [];
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, ''));
|
|
const row: Record<string, string> = {};
|
|
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<GraphUser[]> {
|
|
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<string[]> {
|
|
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<TeamsActivityRow[]> {
|
|
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<EmailActivityRow[]> {
|
|
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<UserMessage[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<CalendarEvent[]> {
|
|
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;
|
|
}
|
|
}
|