const GRAPH = "https://graph.microsoft.com/v1.0"; function escapeODataString(s: string): string { return s.replace(/'/g, "''"); } export interface GraphMessage { id: string; subject?: string; } export interface GraphMailFolder { id: string; displayName: string; } async function graphJson(token: string, pathOrUrl: string, init?: RequestInit): Promise { const url = pathOrUrl.startsWith("https://") ? pathOrUrl : `${GRAPH}${pathOrUrl}`; const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, ...(init?.headers ?? {}), }, }); if (!res.ok) { const text = await res.text(); throw new Error(`Graph ${res.status}: ${text || res.statusText}`); } return res.json() as Promise; } export async function listInboxChildFolders(token: string): Promise { const out: GraphMailFolder[] = []; let next: string | null = "/me/mailFolders/inbox/childFolders?$select=id,displayName&$top=999"; while (next) { const page = await graphJson<{ value: GraphMailFolder[]; "@odata.nextLink"?: string }>( token, next, ); out.push(...page.value); next = page["@odata.nextLink"] ?? null; } return out; } export async function findChildFolderByName( token: string, displayName: string, ): Promise { const folders = await listInboxChildFolders(token); return folders.find((f) => f.displayName === displayName); } export async function createInboxChildFolder( token: string, displayName: string, ): Promise { return graphJson(token, "/me/mailFolders/inbox/childFolders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ displayName }), }); } export async function findOrCreateInboxChildFolder( token: string, displayName: string, ): Promise { const existing = await findChildFolderByName(token, displayName); if (existing) { return existing; } return createInboxChildFolder(token, displayName); } async function listMessagesMatchingFilter( token: string, filter: string, ): Promise { const out: GraphMessage[] = []; let next: string | null = `/me/messages?$filter=${encodeURIComponent(filter)}&$select=id,subject&$top=999`; while (next) { const page = await graphJson<{ value: GraphMessage[]; "@odata.nextLink"?: string }>( token, next, ); out.push(...page.value); next = page["@odata.nextLink"] ?? null; } return out; } /** * Finds messages whose subject or preview contains "TrackingID#". * Falls back to subject-only filter if the tenant rejects bodyPreview in $filter. */ export async function findMessagesWithTrackingId( token: string, trackingId: string, ): Promise { const needle = `TrackingID#${trackingId}`; const q = escapeODataString(needle); const combined = `(contains(subject,'${q}') or contains(bodyPreview,'${q}'))`; const subjectOnly = `contains(subject,'${q}')`; try { return await listMessagesMatchingFilter(token, combined); } catch { return listMessagesMatchingFilter(token, subjectOnly); } } export async function moveMessageToFolder( token: string, messageId: string, destinationFolderId: string, ): Promise { await graphJson(token, `/me/messages/${encodeURIComponent(messageId)}/move`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ destinationId: destinationFolderId }), }); }