2026-03-11 09:34:51 -04:00
/ * *
* 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 ;
}
2026-04-01 10:09:38 -04:00
/ * *
* 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 ;
receivedAfter? : string ;
receivedBefore? : string ;
maxResults? : number ;
} ) : Promise < { id : string ; subject : string ; from : string ; receivedDateTime : string ; isRead : boolean } [ ] > {
const filters : string [ ] = [ ] ;
if ( options . fromAddress ) {
filters . push ( ` from/emailAddress/address eq ' ${ options . fromAddress . replace ( /'/g , "''" ) } ' ` ) ;
}
if ( options . subject ) {
filters . push ( ` contains(subject,' ${ options . subject . replace ( /'/g , "''" ) } ') ` ) ;
}
if ( options . receivedAfter ) {
filters . push ( ` receivedDateTime ge ${ options . receivedAfter } ` ) ;
}
if ( options . receivedBefore ) {
filters . push ( ` receivedDateTime le ${ options . receivedBefore } ` ) ;
}
if ( ! filters . length ) throw new Error ( 'At least one search filter required' ) ;
const top = Math . min ( options . maxResults ? ? 50 , 100 ) ;
const qs = ` $ filter= ${ encodeURIComponent ( filters . join ( ' and ' ) ) } & $ select=id,subject,from,receivedDateTime,isRead& $ top= ${ top } & $ orderby=receivedDateTime desc ` ;
const url = ` /users/ ${ encodeURIComponent ( options . userEmail ) } /messages? ${ qs } ` ;
const data = await this . fetchJson < { value : any [ ] } > ( url ) ;
return ( data . value ? ? [ ] ) . map ( m = > ( {
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 } ` ) ;
}
2026-03-11 09:34:51 -04:00
/ * *
* 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 ;
}
}