Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
1332 lines
50 KiB
TypeScript
1332 lines
50 KiB
TypeScript
import axios, { AxiosInstance } from 'axios';
|
|
import { randomUUID } from 'crypto';
|
|
import { BaseEmailAdapter } from './base/BaseEmailAdapter';
|
|
import { EmailMessageDetails, EmailProviderConfig } from '../../../interfaces/inbound-email.interfaces';
|
|
import type {
|
|
Microsoft365DiagnosticsOptions,
|
|
Microsoft365DiagnosticsReport,
|
|
Microsoft365DiagnosticsStep,
|
|
DiagnosticsStepStatus,
|
|
} from '../../../interfaces/microsoft365-diagnostics.interfaces';
|
|
import { getSecretProviderInstance } from '../../../core/secretProvider';
|
|
import { getAdminConnection } from '../../../db/admin';
|
|
|
|
/**
|
|
* Microsoft Graph API adapter for email processing
|
|
* Handles OAuth authentication, webhook subscriptions, and message retrieval
|
|
*/
|
|
export class MicrosoftGraphAdapter extends BaseEmailAdapter {
|
|
private httpClient: AxiosInstance;
|
|
private baseUrl = 'https://graph.microsoft.com/v1.0';
|
|
private authenticatedUserEmail: string | undefined; // Email of the user who authorized the app
|
|
|
|
constructor(config: EmailProviderConfig) {
|
|
super(config);
|
|
|
|
// Create axios instance with default headers
|
|
this.httpClient = axios.create({
|
|
baseURL: this.baseUrl,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
timeout: 30000,
|
|
});
|
|
|
|
// Add request interceptor to include auth token
|
|
this.httpClient.interceptors.request.use(async (config) => {
|
|
await this.ensureValidToken();
|
|
if (this.accessToken) {
|
|
config.headers.Authorization = `Bearer ${this.accessToken}`;
|
|
}
|
|
return config;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build Microsoft Graph base path for the configured mailbox.
|
|
* Auto-detects whether to use /me or /users/{mailbox} based on:
|
|
* - If configured mailbox matches the authenticated user → use /me (personal account, no admin consent needed)
|
|
* - If configured mailbox differs → use /users/{mailbox} (shared/delegated mailbox)
|
|
*/
|
|
private getMailboxBasePath(): string {
|
|
const configuredMailbox = (this.config.mailbox || '').trim();
|
|
|
|
// If no mailbox configured, use current user (/me)
|
|
if (!configuredMailbox) {
|
|
return '/me';
|
|
}
|
|
|
|
// If we have the authenticated user's email, compare it with the configured mailbox
|
|
if (this.authenticatedUserEmail) {
|
|
// Normalize emails for comparison (case-insensitive)
|
|
const normalizedConfigured = configuredMailbox.toLowerCase();
|
|
const normalizedAuthenticated = this.authenticatedUserEmail.toLowerCase();
|
|
|
|
// If they match, this is the authenticated user's personal mailbox → use /me
|
|
if (normalizedConfigured === normalizedAuthenticated) {
|
|
this.log('info', 'Using /me path for personal mailbox', {
|
|
authenticatedUser: normalizedAuthenticated,
|
|
configuredMailbox: normalizedConfigured
|
|
});
|
|
return '/me';
|
|
}
|
|
|
|
// Otherwise, it's a shared or delegated mailbox → use /users/{mailbox}
|
|
this.log('info', 'Using /users/{mailbox} path for shared/delegated mailbox', {
|
|
authenticatedUser: normalizedAuthenticated,
|
|
configuredMailbox: normalizedConfigured
|
|
});
|
|
return `/users/${encodeURIComponent(configuredMailbox)}`;
|
|
}
|
|
|
|
// Fallback: if we haven't fetched authenticated user email yet, assume /users/{mailbox}
|
|
// This will be corrected once loadAuthenticatedUserEmail() is called
|
|
this.log('warn', 'Authenticated user email not yet loaded; using /users/{mailbox} path');
|
|
return `/users/${encodeURIComponent(configuredMailbox)}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve a folder resource path for subscriptions and message retrieval.
|
|
*/
|
|
private async buildFolderResourcePath(desiredFolder: string): Promise<{ resource: string; resolvedFolder: string }> {
|
|
const mailboxBase = this.getMailboxBasePath();
|
|
const fallbackResult = {
|
|
resource: `${mailboxBase}/mailFolders/inbox/messages`,
|
|
resolvedFolder: 'Inbox (well-known)',
|
|
};
|
|
|
|
const requested = (desiredFolder || 'Inbox').trim();
|
|
if (!requested) {
|
|
return fallbackResult;
|
|
}
|
|
|
|
// Prefer Graph "well-known folder names" (path segment) over display names.
|
|
// This avoids issues where default folders are localized or not resolved by display name.
|
|
const wellKnownMap: Record<string, string> = {
|
|
inbox: 'inbox',
|
|
archive: 'archive',
|
|
drafts: 'drafts',
|
|
deleteditems: 'deleteditems',
|
|
junkemail: 'junkemail',
|
|
sentitems: 'sentitems',
|
|
outbox: 'outbox',
|
|
conversationhistory: 'conversationhistory',
|
|
clutter: 'clutter',
|
|
conflicts: 'conflicts',
|
|
localfailures: 'localfailures',
|
|
serverfailures: 'serverfailures',
|
|
syncissues: 'syncissues',
|
|
};
|
|
|
|
const normalizedKey = requested.toLowerCase().replace(/\s+/g, '');
|
|
if (wellKnownMap[normalizedKey]) {
|
|
return {
|
|
resource: `${mailboxBase}/mailFolders/${wellKnownMap[normalizedKey]}/messages`,
|
|
resolvedFolder: `${requested} (well-known)`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const list = await this.httpClient.get(`${mailboxBase}/mailFolders`, {
|
|
params: { $select: 'id,displayName' },
|
|
});
|
|
const match = (list.data?.value || []).find(
|
|
(f: any) => (f.displayName || '').toLowerCase() === requested.toLowerCase()
|
|
);
|
|
if (match?.id) {
|
|
return {
|
|
resource: `${mailboxBase}/mailFolders/${encodeURIComponent(String(match.id))}/messages`,
|
|
resolvedFolder: match.displayName || requested,
|
|
};
|
|
}
|
|
this.log('warn', `Folder '${requested}' not found; defaulting subscription to Inbox`);
|
|
} catch (error: any) {
|
|
this.log('warn', `Failed to resolve folder '${requested}'; defaulting to Inbox`, error?.message || error);
|
|
}
|
|
|
|
return fallbackResult;
|
|
}
|
|
|
|
/**
|
|
* Load stored credentials from the secret provider
|
|
*/
|
|
protected async loadCredentials(): Promise<void> {
|
|
try {
|
|
const vendorConfig = this.config.provider_config || {};
|
|
|
|
// Preferred: load from DB-backed provider_config (parity with Gmail)
|
|
if (vendorConfig.access_token && vendorConfig.refresh_token) {
|
|
this.accessToken = vendorConfig.access_token;
|
|
this.refreshToken = vendorConfig.refresh_token;
|
|
this.tokenExpiresAt = vendorConfig.token_expires_at
|
|
? new Date(vendorConfig.token_expires_at)
|
|
: undefined;
|
|
this.log('info', 'Loaded Microsoft OAuth credentials from provider configuration');
|
|
return;
|
|
}
|
|
|
|
// Temporary fallback: read from tenant secret storage (read-only)
|
|
try {
|
|
const secretProvider = await getSecretProviderInstance();
|
|
const secret = await secretProvider.getTenantSecret(
|
|
this.config.tenant,
|
|
'email_provider_credentials'
|
|
);
|
|
if (secret) {
|
|
const allCredentials = JSON.parse(secret);
|
|
const credentials = allCredentials[this.config.id];
|
|
if (credentials && credentials.provider === 'microsoft') {
|
|
this.accessToken = credentials.accessToken;
|
|
this.refreshToken = credentials.refreshToken;
|
|
this.tokenExpiresAt = credentials.accessTokenExpiresAt
|
|
? new Date(credentials.accessTokenExpiresAt)
|
|
: undefined;
|
|
this.log('info', 'Loaded Microsoft OAuth credentials from secrets (fallback)');
|
|
return;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
this.log('warn', 'Failed to read credentials from secrets provider (fallback).');
|
|
}
|
|
|
|
throw new Error('Microsoft OAuth tokens not found. Please complete authorization.');
|
|
} catch (error) {
|
|
throw this.handleError(error, 'loadCredentials');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the authenticated user's email address from /me endpoint
|
|
* This is used to auto-detect whether the configured mailbox is a personal account
|
|
* or a shared/delegated mailbox
|
|
*/
|
|
private async loadAuthenticatedUserEmail(): Promise<void> {
|
|
try {
|
|
// Query /me endpoint to get the authenticated user's principal email
|
|
const response = await this.httpClient.get('/me', {
|
|
params: {
|
|
$select: 'userPrincipalName,mail'
|
|
}
|
|
});
|
|
|
|
// Prefer userPrincipalName (common format), fallback to mail field
|
|
this.authenticatedUserEmail = response.data.userPrincipalName || response.data.mail;
|
|
|
|
if (this.authenticatedUserEmail) {
|
|
this.log('info', 'Loaded authenticated user email for mailbox detection', {
|
|
email: this.authenticatedUserEmail
|
|
});
|
|
} else {
|
|
this.log('warn', 'Could not determine authenticated user email from /me endpoint');
|
|
}
|
|
} catch (error) {
|
|
// Non-fatal error: log but don't throw
|
|
// The adapter will still work, it will just default to /users/{mailbox} path
|
|
this.log('warn', 'Failed to load authenticated user email', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh the access token using Microsoft OAuth
|
|
*/
|
|
protected async refreshAccessToken(): Promise<void> {
|
|
try {
|
|
if (!this.refreshToken) {
|
|
throw new Error('No refresh token available');
|
|
}
|
|
|
|
const vendorConfig = this.config.provider_config || {};
|
|
|
|
// Prefer env or provider_config, then fallback to tenant secrets
|
|
let clientId = vendorConfig.client_id || process.env.MICROSOFT_CLIENT_ID;
|
|
let clientSecret = vendorConfig.client_secret || process.env.MICROSOFT_CLIENT_SECRET;
|
|
|
|
if (!clientId || !clientSecret) {
|
|
const secretProvider = await getSecretProviderInstance();
|
|
clientId = clientId || (await secretProvider.getTenantSecret(this.config.tenant, 'microsoft_client_id'));
|
|
clientSecret = clientSecret || (await secretProvider.getTenantSecret(this.config.tenant, 'microsoft_client_secret'));
|
|
}
|
|
|
|
if (!clientId || !clientSecret) {
|
|
throw new Error('Microsoft OAuth credentials not configured');
|
|
}
|
|
|
|
// Determine tenant authority for single-tenant apps
|
|
const vendorTenantId = (this.config.provider_config as any)?.tenant_id || this.config.provider_config?.tenantId;
|
|
let tenantAuthority = vendorTenantId || process.env.MICROSOFT_TENANT_ID;
|
|
if (!tenantAuthority) {
|
|
try {
|
|
const secretProvider = await getSecretProviderInstance();
|
|
tenantAuthority = await secretProvider.getTenantSecret(this.config.tenant, 'microsoft_tenant_id')
|
|
|| await secretProvider.getAppSecret('MICROSOFT_TENANT_ID')
|
|
|| 'common';
|
|
} catch {
|
|
tenantAuthority = 'common';
|
|
}
|
|
}
|
|
|
|
const tokenUrl = `https://login.microsoftonline.com/common/oauth2/v2.0/token`;
|
|
const params = new URLSearchParams({
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
refresh_token: this.refreshToken,
|
|
grant_type: 'refresh_token',
|
|
scope: 'https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/Mail.Read.Shared offline_access',
|
|
});
|
|
|
|
const response = await axios.post(tokenUrl, params.toString(), {
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
});
|
|
|
|
const { access_token, refresh_token, expires_in } = response.data;
|
|
|
|
this.accessToken = access_token;
|
|
if (refresh_token) {
|
|
this.refreshToken = refresh_token;
|
|
}
|
|
|
|
// Calculate expiry with 5-minute buffer
|
|
const expiryTime = new Date(Date.now() + (Number(expires_in || 3600) - 300) * 1000);
|
|
this.tokenExpiresAt = expiryTime;
|
|
|
|
// Update stored credentials (DB + in-memory config)
|
|
await this.updateStoredCredentials();
|
|
|
|
this.log('info', 'Access token refreshed successfully');
|
|
} catch (error) {
|
|
throw this.handleError(error, 'refreshAccessToken');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update stored credentials with new tokens
|
|
*/
|
|
private async updateStoredCredentials(): Promise<void> {
|
|
try {
|
|
// Update in-memory provider_config
|
|
if (!this.config.provider_config) this.config.provider_config = {};
|
|
this.config.provider_config.access_token = this.accessToken;
|
|
this.config.provider_config.refresh_token = this.refreshToken;
|
|
this.config.provider_config.token_expires_at = this.tokenExpiresAt?.toISOString();
|
|
|
|
// Persist to DB (parity with Gmail)
|
|
try {
|
|
const knex = await getAdminConnection();
|
|
await knex('microsoft_email_provider_config')
|
|
.where('email_provider_id', this.config.id)
|
|
.andWhere('tenant', this.config.tenant)
|
|
.update({
|
|
access_token: this.accessToken,
|
|
refresh_token: this.refreshToken,
|
|
token_expires_at: this.tokenExpiresAt?.toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
this.log('info', 'Persisted refreshed Microsoft OAuth tokens to database');
|
|
} catch (dbErr: any) {
|
|
this.log('error', `Failed to persist Microsoft credentials to DB: ${dbErr?.message}`);
|
|
}
|
|
} catch (error) {
|
|
this.log('warn', 'Failed to update stored credentials', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect to Microsoft Graph API
|
|
*/
|
|
async connect(): Promise<void> {
|
|
try {
|
|
await this.loadCredentials();
|
|
// Load authenticated user email for mailbox path auto-detection
|
|
await this.loadAuthenticatedUserEmail();
|
|
await this.testConnection();
|
|
this.log('info', 'Connected to Microsoft Graph API successfully');
|
|
} catch (error) {
|
|
throw this.handleError(error, 'connect');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register webhook subscription for incoming messages
|
|
*/
|
|
async registerWebhookSubscription(): Promise<void> {
|
|
try {
|
|
const webhookUrl = this.config.webhook_notification_url;
|
|
if (!webhookUrl) {
|
|
throw new Error('Webhook notification URL not configured');
|
|
}
|
|
|
|
// Microsoft Graph limit for Outlook message subscriptions is 4230 minutes (~70.5 hours)
|
|
// Use a safe window (e.g., 60 hours) to avoid 400 due to out-of-range expiration
|
|
const expirationMs = 60 * 60 * 1000 * 60; // 60 hours in ms
|
|
|
|
const desiredFolder = (this.config.folder_to_monitor || 'Inbox').trim();
|
|
const { resource, resolvedFolder } = await this.buildFolderResourcePath(desiredFolder);
|
|
const mailboxBase = this.getMailboxBasePath();
|
|
|
|
const subscription = {
|
|
changeType: 'created',
|
|
notificationUrl: webhookUrl,
|
|
resource,
|
|
expirationDateTime: new Date(Date.now() + expirationMs).toISOString(),
|
|
clientState: this.config.webhook_verification_token || 'email-webhook-verification',
|
|
};
|
|
|
|
// Log payload with masked clientState for diagnostics
|
|
const maskedState = subscription.clientState
|
|
? `${String(subscription.clientState).slice(0, 4)}...(${String(subscription.clientState).length})`
|
|
: 'none';
|
|
this.log('info', 'Creating Microsoft subscription', {
|
|
notificationUrl: subscription.notificationUrl,
|
|
resource: subscription.resource,
|
|
expirationDateTime: subscription.expirationDateTime,
|
|
clientState: maskedState,
|
|
mailboxBase,
|
|
folder: resolvedFolder,
|
|
});
|
|
|
|
const response = await this.httpClient.post('/subscriptions', subscription);
|
|
|
|
// Update config with subscription ID
|
|
this.config.webhook_subscription_id = response.data.id;
|
|
this.config.webhook_expires_at = response.data.expirationDateTime;
|
|
|
|
// Persist webhook details only in microsoft vendor config
|
|
try {
|
|
const knex = await getAdminConnection();
|
|
await knex('microsoft_email_provider_config')
|
|
.where('email_provider_id', this.config.id)
|
|
.andWhere('tenant', this.config.tenant)
|
|
.update({
|
|
webhook_subscription_id: response.data.id,
|
|
webhook_expires_at: response.data.expirationDateTime,
|
|
webhook_verification_token: this.config.webhook_verification_token || null,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
} catch (dbErr: any) {
|
|
this.log('warn', `Failed to persist Microsoft webhook subscription: ${dbErr?.message}`);
|
|
}
|
|
|
|
this.log('info', `Webhook subscription created: ${response.data.id}`);
|
|
} catch (error) {
|
|
// Enrich/log details (status, request-id, body) before throwing
|
|
const enriched = this.handleError(error, 'registerWebhookSubscription');
|
|
this.log('error', 'Subscription creation failed', {
|
|
message: enriched.message,
|
|
context: 'registerWebhookSubscription',
|
|
status: (enriched as any).status,
|
|
code: (enriched as any).code,
|
|
requestId: (enriched as any).requestId,
|
|
responseBody: (enriched as any).responseBody,
|
|
});
|
|
throw enriched;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renew webhook subscription before expiration
|
|
*/
|
|
async renewWebhookSubscription(): Promise<void> {
|
|
try {
|
|
if (!this.config.webhook_subscription_id) {
|
|
throw new Error('No webhook subscription to renew');
|
|
}
|
|
|
|
const newExpiry = new Date(Date.now() + (3 * 24 * 60 * 60 * 1000)).toISOString();
|
|
|
|
await this.httpClient.patch(`/subscriptions/${this.config.webhook_subscription_id}`, {
|
|
expirationDateTime: newExpiry,
|
|
});
|
|
|
|
this.config.webhook_expires_at = newExpiry;
|
|
this.config.last_subscription_renewal = new Date().toISOString();
|
|
|
|
// Persist renewal
|
|
try {
|
|
const knex = await getAdminConnection();
|
|
await knex('microsoft_email_provider_config')
|
|
.where('email_provider_id', this.config.id)
|
|
.andWhere('tenant', this.config.tenant)
|
|
.update({
|
|
webhook_expires_at: newExpiry,
|
|
last_subscription_renewal: this.config.last_subscription_renewal,
|
|
updated_at: new Date().toISOString()
|
|
});
|
|
} catch (dbErr: any) {
|
|
this.log('warn', `Failed to persist webhook renewal: ${dbErr?.message}`);
|
|
}
|
|
|
|
this.log('info', `Webhook subscription renewed until ${newExpiry}`);
|
|
} catch (error) {
|
|
const enriched = this.handleError(error, 'renewWebhookSubscription');
|
|
this.log('error', 'Subscription renewal failed', {
|
|
message: enriched.message,
|
|
context: 'renewWebhookSubscription',
|
|
subscriptionId: this.config.webhook_subscription_id,
|
|
status: (enriched as any).status,
|
|
code: (enriched as any).code,
|
|
requestId: (enriched as any).requestId,
|
|
responseBody: (enriched as any).responseBody,
|
|
});
|
|
throw enriched;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark a message as read (READ-ONLY MODE: No-op)
|
|
* Note: This system now operates in read-only mode and does not modify emails.
|
|
* Email processing status is tracked in the database instead.
|
|
*/
|
|
async markMessageProcessed(messageId: string): Promise<void> {
|
|
this.log('info', `Email ${messageId} processed (read-only mode - not marking as read in mailbox)`);
|
|
// No API call made - operating in read-only mode
|
|
}
|
|
|
|
/**
|
|
* Get detailed message information
|
|
*/
|
|
async getMessageDetails(messageId: string): Promise<EmailMessageDetails> {
|
|
try {
|
|
const mailboxBase = this.getMailboxBasePath();
|
|
const response = await this.httpClient.get(`${mailboxBase}/messages/${messageId}`, {
|
|
params: {
|
|
$expand: 'attachments',
|
|
$select:
|
|
'internetMessageHeaders,receivedDateTime,subject,body,bodyPreview,from,toRecipients,ccRecipients,conversationId',
|
|
},
|
|
headers: {
|
|
Prefer: 'outlook.body-content-type="html"',
|
|
},
|
|
});
|
|
|
|
const message = response.data;
|
|
const bodyContentType = String(message.body?.contentType || '').toLowerCase();
|
|
const htmlBody = bodyContentType === 'html' ? message.body?.content : undefined;
|
|
const textBody = bodyContentType === 'text'
|
|
? message.body?.content || ''
|
|
: message.bodyPreview || '';
|
|
|
|
return {
|
|
id: message.id,
|
|
provider: 'microsoft',
|
|
providerId: this.config.id,
|
|
receivedAt: message.receivedDateTime,
|
|
from: {
|
|
email: message.from?.emailAddress?.address || '',
|
|
name: message.from?.emailAddress?.name,
|
|
},
|
|
to: message.toRecipients?.map((recipient: any) => ({
|
|
email: recipient.emailAddress?.address || '',
|
|
name: recipient.emailAddress?.name,
|
|
})) || [],
|
|
cc: message.ccRecipients?.map((recipient: any) => ({
|
|
email: recipient.emailAddress?.address || '',
|
|
name: recipient.emailAddress?.name,
|
|
})),
|
|
subject: message.subject || '',
|
|
body: {
|
|
text: textBody,
|
|
html: htmlBody,
|
|
},
|
|
attachments: message.attachments?.map((attachment: any) => ({
|
|
id: attachment.id,
|
|
name: attachment.name,
|
|
contentType: attachment.contentType,
|
|
size: attachment.size,
|
|
contentId: attachment.contentId,
|
|
isInline: attachment.isInline,
|
|
})),
|
|
threadId: message.conversationId,
|
|
references: message.internetMessageHeaders?.find((h: any) => h.name === 'References')?.value?.split(' '),
|
|
inReplyTo: message.internetMessageHeaders?.find((h: any) => h.name === 'In-Reply-To')?.value,
|
|
tenant: this.config.tenant,
|
|
headers: message.internetMessageHeaders?.reduce((acc: any, header: any) => {
|
|
acc[header.name] = header.value;
|
|
return acc;
|
|
}, {}),
|
|
messageSize: message.bodyPreview?.length,
|
|
importance: message.importance,
|
|
sensitivity: message.sensitivity,
|
|
};
|
|
} catch (error) {
|
|
throw this.handleError(error, 'getMessageDetails');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download a file attachment's bytes.
|
|
*
|
|
* Notes:
|
|
* - Graph's attachment payload commonly includes base64 `contentBytes` for fileAttachment.
|
|
* - We intentionally skip item/reference attachments here; callers can treat them as unsupported.
|
|
*/
|
|
async downloadAttachmentBytes(messageId: string, attachmentId: string): Promise<{
|
|
fileName: string;
|
|
contentType: string;
|
|
size: number;
|
|
contentId?: string;
|
|
isInline?: boolean;
|
|
buffer: Buffer;
|
|
}> {
|
|
try {
|
|
const mailboxBase = this.getMailboxBasePath();
|
|
const response = await this.httpClient.get(
|
|
`${mailboxBase}/messages/${messageId}/attachments/${attachmentId}`
|
|
);
|
|
|
|
const att = response.data;
|
|
const odataType: string | undefined = att?.['@odata.type'];
|
|
const isFileAttachment = !odataType || String(odataType).toLowerCase().includes('fileattachment');
|
|
if (!isFileAttachment) {
|
|
throw new Error(`Unsupported attachment type: ${odataType || 'unknown'}`);
|
|
}
|
|
|
|
const contentBytes: string | undefined = att?.contentBytes;
|
|
if (!contentBytes) {
|
|
throw new Error('Attachment contentBytes missing');
|
|
}
|
|
|
|
const buffer = Buffer.from(contentBytes, 'base64');
|
|
return {
|
|
fileName: att?.name || attachmentId,
|
|
contentType: att?.contentType || 'application/octet-stream',
|
|
size: typeof att?.size === 'number' ? att.size : buffer.length,
|
|
contentId: att?.contentId || undefined,
|
|
isInline: typeof att?.isInline === 'boolean' ? att.isInline : undefined,
|
|
buffer,
|
|
};
|
|
} catch (error) {
|
|
throw this.handleError(error, 'downloadAttachmentBytes');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download full RFC822 source bytes for a message.
|
|
*/
|
|
async downloadMessageSource(messageId: string): Promise<Buffer> {
|
|
try {
|
|
const mailboxBase = this.getMailboxBasePath();
|
|
const response = await this.httpClient.get(`${mailboxBase}/messages/${messageId}/$value`, {
|
|
responseType: 'arraybuffer',
|
|
headers: {
|
|
Accept: 'message/rfc822',
|
|
},
|
|
});
|
|
|
|
return Buffer.from(response.data);
|
|
} catch (error) {
|
|
throw this.handleError(error, 'downloadMessageSource');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test the connection to Microsoft Graph
|
|
*/
|
|
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const mailboxBase = this.getMailboxBasePath();
|
|
await this.httpClient.get(mailboxBase);
|
|
await this.httpClient.get(`${mailboxBase}/mailFolders`, {
|
|
params: { $top: 1, $select: 'id' },
|
|
});
|
|
return { success: true };
|
|
} catch (error: any) {
|
|
return { success: false, error: error.message || 'Connection test failed' };
|
|
}
|
|
}
|
|
|
|
private buildTokenFingerprint(token?: string): string | undefined {
|
|
if (!token) return undefined;
|
|
return `${token.slice(0, 4)}...(${token.length})`;
|
|
}
|
|
|
|
private decodeJwtPayload(token: string): Record<string, any> | null {
|
|
try {
|
|
const parts = token.split('.');
|
|
if (parts.length < 2) return null;
|
|
const payload = parts[1];
|
|
const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=');
|
|
const json = Buffer.from(padded, 'base64').toString('utf8');
|
|
return JSON.parse(json);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private extractGraphIds(headers: any): { requestId?: string; clientRequestId?: string } {
|
|
const lower = (k: string) => (headers?.[k] ?? headers?.[k.toLowerCase()]);
|
|
return {
|
|
requestId: lower('request-id'),
|
|
clientRequestId: lower('client-request-id'),
|
|
};
|
|
}
|
|
|
|
private classifyGraphFailure(error: any): {
|
|
status?: number;
|
|
code?: string;
|
|
message: string;
|
|
requestId?: string;
|
|
clientRequestId?: string;
|
|
responseBody?: unknown;
|
|
} {
|
|
const res = error?.response;
|
|
const status = res?.status;
|
|
const graphErr = res?.data?.error || res?.data;
|
|
const message =
|
|
graphErr?.message ||
|
|
error?.message ||
|
|
(typeof error === 'string' ? error : 'Unknown error');
|
|
const code = graphErr?.code || (status ? String(status) : undefined);
|
|
const ids = this.extractGraphIds(res?.headers);
|
|
return {
|
|
status,
|
|
code,
|
|
message,
|
|
requestId: ids.requestId,
|
|
clientRequestId: ids.clientRequestId,
|
|
responseBody: res?.data,
|
|
};
|
|
}
|
|
|
|
private mapRecommendations(args: {
|
|
status?: number;
|
|
code?: string;
|
|
message: string;
|
|
missingScopes?: string[];
|
|
}): string[] {
|
|
const recs: string[] = [];
|
|
|
|
if (args.missingScopes?.length) {
|
|
recs.push(
|
|
`Missing delegated scopes in the access token: ${args.missingScopes.join(', ')}. Re-authorize with Mail.Read and Mail.Read.Shared (and ensure admin consent if required).`
|
|
);
|
|
}
|
|
|
|
if (args.status === 401) {
|
|
recs.push('Microsoft authorization appears invalid/expired. Re-authorize the Microsoft provider to refresh consent and tokens.');
|
|
}
|
|
|
|
if (args.status === 403) {
|
|
recs.push(
|
|
'Microsoft Graph returned 403 (Forbidden). Verify the user has delegated access to the target mailbox/folder and that Mail.Read/Mail.Read.Shared consent was granted.'
|
|
);
|
|
}
|
|
|
|
if (args.status === 404) {
|
|
const msg = (args.message || '').toLowerCase();
|
|
if (msg.includes('default folder inbox not found') || msg.includes('specified object was not found in the store')) {
|
|
recs.push(
|
|
'Graph reports the mailbox store/folder is missing. Confirm the address is a real user/shared mailbox (not a group/contact) and that the mailbox is provisioned (can be opened in Outlook/OWA).'
|
|
);
|
|
} else {
|
|
recs.push('Graph returned 404 (Not Found). Verify the mailbox address is correct for this tenant, and the folder exists and is accessible.');
|
|
}
|
|
}
|
|
|
|
if (args.status === 429) {
|
|
recs.push('Microsoft Graph throttled the request (429). Wait and retry; consider reducing repeated diagnostics runs.');
|
|
}
|
|
|
|
return recs;
|
|
}
|
|
|
|
private computeOverallStatus(steps: Microsoft365DiagnosticsStep[]): DiagnosticsStepStatus {
|
|
if (steps.some((s) => s.status === 'fail')) return 'fail';
|
|
if (steps.some((s) => s.status === 'warn')) return 'warn';
|
|
return 'pass';
|
|
}
|
|
|
|
/**
|
|
* Run a structured Microsoft 365 diagnostics checklist for this provider.
|
|
*
|
|
* NOTE: This is intended for admin self-serve troubleshooting. It always redacts secrets,
|
|
* and (optionally) performs a live create+delete subscription test.
|
|
*/
|
|
async runMicrosoft365Diagnostics(options: Microsoft365DiagnosticsOptions = {}): Promise<Microsoft365DiagnosticsReport> {
|
|
const startedAt = new Date().toISOString();
|
|
const steps: Microsoft365DiagnosticsStep[] = [];
|
|
const recommendations = new Set<string>();
|
|
|
|
const requiredScopes = options.requiredScopes?.length
|
|
? options.requiredScopes
|
|
: ['Mail.Read', 'Mail.Read.Shared'];
|
|
|
|
const folderListTop = Math.max(1, Math.min(options.folderListTop ?? 100, 250));
|
|
|
|
const addStep = (step: Microsoft365DiagnosticsStep) => steps.push(step);
|
|
|
|
const runStep = async (id: string, title: string, fn: () => Promise<Omit<Microsoft365DiagnosticsStep, 'id' | 'title' | 'startedAt' | 'durationMs'>>): Promise<void> => {
|
|
const stepStarted = Date.now();
|
|
const stepIso = new Date().toISOString();
|
|
try {
|
|
const partial = await fn();
|
|
addStep({
|
|
id,
|
|
title,
|
|
startedAt: stepIso,
|
|
durationMs: Date.now() - stepStarted,
|
|
status: partial.status,
|
|
http: partial.http,
|
|
data: partial.data,
|
|
error: partial.error,
|
|
});
|
|
} catch (e: any) {
|
|
const classified = this.classifyGraphFailure(e);
|
|
this.mapRecommendations({ ...classified, missingScopes: undefined }).forEach((r) => recommendations.add(r));
|
|
addStep({
|
|
id,
|
|
title,
|
|
startedAt: stepIso,
|
|
durationMs: Date.now() - stepStarted,
|
|
status: 'fail',
|
|
error: {
|
|
message: classified.message,
|
|
status: classified.status,
|
|
code: classified.code,
|
|
requestId: classified.requestId,
|
|
clientRequestId: classified.clientRequestId,
|
|
responseBody: classified.responseBody,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
// Step: load credentials (tokens present)
|
|
await runStep('tokens_present', 'Load stored OAuth tokens', async () => {
|
|
try {
|
|
await this.loadCredentials();
|
|
return {
|
|
status: 'pass' as const,
|
|
data: {
|
|
accessToken: this.buildTokenFingerprint(this.accessToken),
|
|
refreshToken: this.buildTokenFingerprint(this.refreshToken),
|
|
tokenExpiresAt: this.tokenExpiresAt?.toISOString(),
|
|
},
|
|
};
|
|
} catch (e: any) {
|
|
const msg = e?.message || 'Microsoft OAuth tokens not found. Please complete authorization.';
|
|
recommendations.add('No Microsoft OAuth tokens are available for this provider. Re-authorize the Microsoft provider to generate tokens.');
|
|
return {
|
|
status: 'fail' as const,
|
|
error: { message: msg },
|
|
};
|
|
}
|
|
});
|
|
|
|
// If tokens didn't load, we can't proceed.
|
|
if (!this.accessToken) {
|
|
const report: Microsoft365DiagnosticsReport = {
|
|
createdAt: startedAt,
|
|
summary: {
|
|
providerId: this.config.id,
|
|
tenantId: this.config.tenant,
|
|
providerType: 'microsoft',
|
|
mailbox: this.config.mailbox,
|
|
folder: (this.config.folder_to_monitor || 'Inbox').trim() || 'Inbox',
|
|
mailboxBasePath: this.getMailboxBasePath(),
|
|
notificationUrl: this.config.webhook_notification_url,
|
|
targetResource: undefined,
|
|
authenticatedUserEmail: undefined,
|
|
tokenExpiresAt: this.tokenExpiresAt?.toISOString(),
|
|
overallStatus: this.computeOverallStatus(steps),
|
|
},
|
|
steps,
|
|
recommendations: Array.from(recommendations),
|
|
supportBundle: {
|
|
createdAt: startedAt,
|
|
providerId: this.config.id,
|
|
tenantId: this.config.tenant,
|
|
providerType: 'microsoft',
|
|
tokens: { accessToken: this.buildTokenFingerprint(this.accessToken), refreshToken: this.buildTokenFingerprint(this.refreshToken) },
|
|
steps,
|
|
recommendations: Array.from(recommendations),
|
|
},
|
|
};
|
|
return report;
|
|
}
|
|
|
|
// Step: decode token claims + scope check
|
|
let decodedScopes: string[] = [];
|
|
await runStep('token_claims', 'Decode access token claims and scopes', async () => {
|
|
const payload = this.decodeJwtPayload(this.accessToken!);
|
|
const scp = typeof payload?.scp === 'string' ? payload!.scp : '';
|
|
decodedScopes = scp ? scp.split(' ').filter(Boolean) : [];
|
|
|
|
const missing = requiredScopes.filter((s) => !decodedScopes.includes(s));
|
|
this.mapRecommendations({ status: undefined, code: undefined, message: '', missingScopes: missing }).forEach((r) => recommendations.add(r));
|
|
|
|
return {
|
|
status: missing.length ? ('warn' as const) : ('pass' as const),
|
|
data: {
|
|
tid: payload?.tid,
|
|
aud: payload?.aud,
|
|
iss: payload?.iss,
|
|
appid: payload?.appid,
|
|
upn: payload?.upn,
|
|
preferred_username: payload?.preferred_username,
|
|
scp: decodedScopes,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Step: /me baseline
|
|
await runStep('graph_me', 'Microsoft Graph /me baseline check', async () => {
|
|
const clientRequestId = randomUUID();
|
|
const res = await this.httpClient.get('/me', {
|
|
params: { $select: 'id,userPrincipalName,mail' },
|
|
headers: { 'client-request-id': clientRequestId, 'return-client-request-id': 'true' },
|
|
});
|
|
const ids = this.extractGraphIds(res.headers);
|
|
this.authenticatedUserEmail = res.data?.userPrincipalName || res.data?.mail;
|
|
return {
|
|
status: 'pass' as const,
|
|
http: {
|
|
method: 'GET',
|
|
path: '/me?$select=id,userPrincipalName,mail',
|
|
status: res.status,
|
|
requestId: ids.requestId,
|
|
clientRequestId: ids.clientRequestId || clientRequestId,
|
|
},
|
|
data: {
|
|
id: res.data?.id,
|
|
userPrincipalName: res.data?.userPrincipalName,
|
|
mail: res.data?.mail,
|
|
},
|
|
};
|
|
});
|
|
|
|
const mailboxBase = this.getMailboxBasePath();
|
|
await runStep('mailbox_base_path', 'Compute mailbox base path decision', async () => {
|
|
const configured = (this.config.mailbox || '').trim();
|
|
const authenticated = (this.authenticatedUserEmail || '').trim();
|
|
const decision = mailboxBase;
|
|
const rationale =
|
|
!configured
|
|
? 'No mailbox configured; defaulting to /me'
|
|
: authenticated && configured.toLowerCase() === authenticated.toLowerCase()
|
|
? 'Configured mailbox matches authenticated user; using /me'
|
|
: 'Configured mailbox differs from authenticated user; using /users/{mailbox}';
|
|
|
|
return {
|
|
status: 'pass' as const,
|
|
data: {
|
|
configuredMailbox: configured,
|
|
authenticatedUserEmail: authenticated || undefined,
|
|
mailboxBasePath: decision,
|
|
rationale,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Step: /users/{mailbox} directory existence (only when using /users)
|
|
await runStep('mailbox_directory', 'Validate mailbox directory object (only for shared/delegated)', async () => {
|
|
if (mailboxBase === '/me') {
|
|
return { status: 'skip' as const, data: { reason: 'Using /me; no /users lookup required.' } };
|
|
}
|
|
|
|
const clientRequestId = randomUUID();
|
|
const res = await this.httpClient.get(mailboxBase, {
|
|
params: { $select: 'id,userPrincipalName,mail' },
|
|
headers: { 'client-request-id': clientRequestId, 'return-client-request-id': 'true' },
|
|
});
|
|
const ids = this.extractGraphIds(res.headers);
|
|
return {
|
|
status: 'pass' as const,
|
|
http: {
|
|
method: 'GET',
|
|
path: `${mailboxBase}?$select=id,userPrincipalName,mail`,
|
|
status: res.status,
|
|
requestId: ids.requestId,
|
|
clientRequestId: ids.clientRequestId || clientRequestId,
|
|
},
|
|
data: {
|
|
id: res.data?.id,
|
|
userPrincipalName: res.data?.userPrincipalName,
|
|
mail: res.data?.mail,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Step: inbox well-known folder check
|
|
await runStep('inbox_well_known', 'Validate well-known Inbox folder exists', async () => {
|
|
const clientRequestId = randomUUID();
|
|
const path = `${mailboxBase}/mailFolders/inbox`;
|
|
const res = await this.httpClient.get(path, {
|
|
params: { $select: 'id,displayName' },
|
|
headers: { 'client-request-id': clientRequestId, 'return-client-request-id': 'true' },
|
|
});
|
|
const ids = this.extractGraphIds(res.headers);
|
|
return {
|
|
status: 'pass' as const,
|
|
http: {
|
|
method: 'GET',
|
|
path: `${path}?$select=id,displayName`,
|
|
status: res.status,
|
|
requestId: ids.requestId,
|
|
clientRequestId: ids.clientRequestId || clientRequestId,
|
|
},
|
|
data: {
|
|
id: res.data?.id,
|
|
displayName: res.data?.displayName,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Step: folder enumeration (used for troubleshooting and custom folder resolution)
|
|
let folders: Array<{ id: string; displayName?: string }> = [];
|
|
await runStep('folder_list', 'List top-level mail folders', async () => {
|
|
const clientRequestId = randomUUID();
|
|
const path = `${mailboxBase}/mailFolders`;
|
|
const res = await this.httpClient.get(path, {
|
|
params: { $select: 'id,displayName', $top: folderListTop },
|
|
headers: { 'client-request-id': clientRequestId, 'return-client-request-id': 'true' },
|
|
});
|
|
const ids = this.extractGraphIds(res.headers);
|
|
folders = (res.data?.value || []).map((f: any) => ({ id: String(f.id), displayName: f.displayName }));
|
|
return {
|
|
status: 'pass' as const,
|
|
http: {
|
|
method: 'GET',
|
|
path: `${path}?$select=id,displayName&$top=${folderListTop}`,
|
|
status: res.status,
|
|
requestId: ids.requestId,
|
|
clientRequestId: ids.clientRequestId || clientRequestId,
|
|
},
|
|
data: {
|
|
count: folders.length,
|
|
truncated: folders.length >= folderListTop,
|
|
sample: folders.slice(0, 25),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Step: resolve configured folder to a resource
|
|
const configuredFolder = (this.config.folder_to_monitor || 'Inbox').trim() || 'Inbox';
|
|
let targetResource: string | undefined;
|
|
await runStep('folder_resolve', 'Resolve configured folder to a Graph resource', async () => {
|
|
const { resource, resolvedFolder } = await this.buildFolderResourcePath(configuredFolder);
|
|
targetResource = resource;
|
|
|
|
// If the configured folder is not Inbox and we couldn't find it in the folder list, warn.
|
|
const normalized = configuredFolder.toLowerCase();
|
|
const hasMatch =
|
|
normalized === 'inbox' ||
|
|
folders.some((f) => (f.displayName || '').toLowerCase() === normalized);
|
|
|
|
if (!hasMatch && normalized !== 'inbox') {
|
|
recommendations.add(`Configured folder '${configuredFolder}' was not found in the top-level folder list. Consider choosing a valid folder name.`);
|
|
}
|
|
|
|
return {
|
|
status: 'pass' as const,
|
|
data: {
|
|
configuredFolder,
|
|
resolvedFolder,
|
|
targetResource: resource,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Step: preflight read from the exact resource we will subscribe to
|
|
await runStep('messages_preflight', 'Preflight message read for target resource', async () => {
|
|
if (!targetResource) {
|
|
return { status: 'fail' as const, error: { message: 'Target resource was not resolved' } };
|
|
}
|
|
const clientRequestId = randomUUID();
|
|
const res = await this.httpClient.get(`${targetResource}`, {
|
|
params: { $top: 1, $select: 'id,receivedDateTime,subject' },
|
|
headers: { 'client-request-id': clientRequestId, 'return-client-request-id': 'true' },
|
|
});
|
|
const ids = this.extractGraphIds(res.headers);
|
|
return {
|
|
status: 'pass' as const,
|
|
http: {
|
|
method: 'GET',
|
|
path: `${targetResource}?$top=1&$select=id,receivedDateTime,subject`,
|
|
status: res.status,
|
|
requestId: ids.requestId,
|
|
clientRequestId: ids.clientRequestId || clientRequestId,
|
|
resource: targetResource,
|
|
},
|
|
data: {
|
|
messagesReadable: true,
|
|
sampleCount: Array.isArray(res.data?.value) ? res.data.value.length : undefined,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Step: live subscription create+delete test (optional, default enabled for admin diagnostics)
|
|
await runStep('subscription_live_test', 'Live subscription create+delete test', async () => {
|
|
if (!options.liveSubscriptionTest) {
|
|
return { status: 'skip' as const, data: { reason: 'Disabled by options' } };
|
|
}
|
|
|
|
const webhookUrl = this.config.webhook_notification_url;
|
|
if (!webhookUrl) {
|
|
recommendations.add('Webhook notification URL is not configured. Save provider settings and ensure a public base URL is configured.');
|
|
return { status: 'fail' as const, error: { message: 'Webhook notification URL not configured' } };
|
|
}
|
|
if (!targetResource) {
|
|
return { status: 'fail' as const, error: { message: 'Target resource was not resolved' } };
|
|
}
|
|
|
|
const createClientRequestId = randomUUID();
|
|
const subscriptionClientState = `diag-${randomUUID()}`;
|
|
const createPayload = {
|
|
changeType: 'created',
|
|
notificationUrl: webhookUrl,
|
|
resource: targetResource,
|
|
expirationDateTime: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
|
|
clientState: subscriptionClientState,
|
|
latestSupportedTlsVersion: 'v1_2',
|
|
};
|
|
|
|
let subscriptionId: string | undefined;
|
|
try {
|
|
const createRes = await this.httpClient.post('/subscriptions', createPayload, {
|
|
headers: { 'client-request-id': createClientRequestId, 'return-client-request-id': 'true' },
|
|
});
|
|
const ids = this.extractGraphIds(createRes.headers);
|
|
subscriptionId = createRes.data?.id;
|
|
|
|
// Best-effort delete to avoid leaving residual subscriptions
|
|
const deleteClientRequestId = randomUUID();
|
|
try {
|
|
const delRes = await this.httpClient.delete(`/subscriptions/${encodeURIComponent(String(subscriptionId))}`, {
|
|
headers: { 'client-request-id': deleteClientRequestId, 'return-client-request-id': 'true' },
|
|
});
|
|
const delIds = this.extractGraphIds(delRes.headers);
|
|
return {
|
|
status: 'pass' as const,
|
|
http: {
|
|
method: 'POST',
|
|
path: '/subscriptions',
|
|
status: createRes.status,
|
|
requestId: ids.requestId,
|
|
clientRequestId: ids.clientRequestId || createClientRequestId,
|
|
resource: targetResource,
|
|
},
|
|
data: {
|
|
createdSubscriptionId: subscriptionId,
|
|
deletedSubscriptionId: subscriptionId,
|
|
deleteRequestId: delIds.requestId,
|
|
deleteClientRequestId: delIds.clientRequestId || deleteClientRequestId,
|
|
},
|
|
};
|
|
} catch (deleteErr: any) {
|
|
const classified = this.classifyGraphFailure(deleteErr);
|
|
recommendations.add(
|
|
`Subscription created (${subscriptionId}) but deletion failed. You may need to manually clean up the subscription in Microsoft 365; Graph request-id: ${classified.requestId || 'unknown'}.`
|
|
);
|
|
return {
|
|
status: 'warn' as const,
|
|
http: {
|
|
method: 'POST',
|
|
path: '/subscriptions',
|
|
status: createRes.status,
|
|
requestId: ids.requestId,
|
|
clientRequestId: ids.clientRequestId || createClientRequestId,
|
|
resource: targetResource,
|
|
},
|
|
data: {
|
|
createdSubscriptionId: subscriptionId,
|
|
deleteFailed: true,
|
|
},
|
|
error: {
|
|
message: classified.message,
|
|
status: classified.status,
|
|
code: classified.code,
|
|
requestId: classified.requestId,
|
|
clientRequestId: classified.clientRequestId,
|
|
responseBody: classified.responseBody,
|
|
},
|
|
};
|
|
}
|
|
} catch (createErr: any) {
|
|
const classified = this.classifyGraphFailure(createErr);
|
|
this.mapRecommendations({ ...classified, missingScopes: undefined }).forEach((r) => recommendations.add(r));
|
|
return {
|
|
status: 'fail' as const,
|
|
http: {
|
|
method: 'POST',
|
|
path: '/subscriptions',
|
|
clientRequestId: createClientRequestId,
|
|
resource: targetResource,
|
|
},
|
|
error: {
|
|
message: classified.message,
|
|
status: classified.status,
|
|
code: classified.code,
|
|
requestId: classified.requestId,
|
|
clientRequestId: classified.clientRequestId || createClientRequestId,
|
|
responseBody: classified.responseBody,
|
|
},
|
|
};
|
|
}
|
|
});
|
|
|
|
// Build final report
|
|
const summaryMailbox = options.includeIdentifiers ? this.config.mailbox : 'redacted';
|
|
const summaryNotificationUrl = options.includeIdentifiers ? this.config.webhook_notification_url : undefined;
|
|
const summaryTargetResource = options.includeIdentifiers ? targetResource : undefined;
|
|
|
|
const report: Microsoft365DiagnosticsReport = {
|
|
createdAt: startedAt,
|
|
summary: {
|
|
providerId: this.config.id,
|
|
tenantId: this.config.tenant,
|
|
providerType: 'microsoft',
|
|
mailbox: summaryMailbox,
|
|
folder: configuredFolder,
|
|
mailboxBasePath: mailboxBase,
|
|
notificationUrl: summaryNotificationUrl,
|
|
targetResource: summaryTargetResource,
|
|
authenticatedUserEmail: options.includeIdentifiers ? this.authenticatedUserEmail : undefined,
|
|
tokenExpiresAt: this.tokenExpiresAt?.toISOString(),
|
|
overallStatus: this.computeOverallStatus(steps),
|
|
},
|
|
steps,
|
|
recommendations: Array.from(recommendations),
|
|
supportBundle: {
|
|
createdAt: startedAt,
|
|
providerId: this.config.id,
|
|
tenantId: this.config.tenant,
|
|
mailbox: summaryMailbox,
|
|
folder: configuredFolder,
|
|
mailboxBasePath: mailboxBase,
|
|
notificationUrl: summaryNotificationUrl,
|
|
targetResource: summaryTargetResource,
|
|
authenticatedUserEmail: options.includeIdentifiers ? this.authenticatedUserEmail : undefined,
|
|
token: {
|
|
accessToken: this.buildTokenFingerprint(this.accessToken),
|
|
refreshToken: this.buildTokenFingerprint(this.refreshToken),
|
|
tokenExpiresAt: this.tokenExpiresAt?.toISOString(),
|
|
decodedScopes,
|
|
},
|
|
steps,
|
|
recommendations: Array.from(recommendations),
|
|
},
|
|
};
|
|
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* Disconnect and cleanup resources
|
|
*/
|
|
async disconnect(): Promise<void> {
|
|
try {
|
|
// Delete webhook subscription if it exists
|
|
if (this.config.webhook_subscription_id) {
|
|
try {
|
|
await this.httpClient.delete(`/subscriptions/${this.config.webhook_subscription_id}`);
|
|
this.log('info', 'Webhook subscription deleted');
|
|
} catch (error) {
|
|
this.log('warn', 'Failed to delete webhook subscription', error);
|
|
}
|
|
}
|
|
|
|
// Clear tokens
|
|
this.accessToken = undefined;
|
|
this.refreshToken = undefined;
|
|
this.tokenExpiresAt = undefined;
|
|
|
|
this.log('info', 'Disconnected from Microsoft Graph API');
|
|
} catch (error) {
|
|
throw this.handleError(error, 'disconnect');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize webhook subscription for email notifications
|
|
*/
|
|
async initializeWebhook(webhookUrl: string): Promise<{ success: boolean; subscriptionId?: string; error?: string }> {
|
|
try {
|
|
this.log('info', `Initializing webhook subscription to ${webhookUrl}`);
|
|
|
|
const expirationMs = 60 * 60 * 1000 * 60; // ~60 hours within Graph limits
|
|
const desiredFolder = (this.config.folder_to_monitor || 'Inbox').trim();
|
|
const { resource, resolvedFolder } = await this.buildFolderResourcePath(desiredFolder);
|
|
const mailboxBase = this.getMailboxBasePath();
|
|
const subscription = {
|
|
changeType: 'created',
|
|
notificationUrl: webhookUrl,
|
|
resource,
|
|
expirationDateTime: new Date(Date.now() + expirationMs).toISOString(),
|
|
clientState: this.config.webhook_verification_token || 'email-webhook-verification',
|
|
};
|
|
|
|
this.log('info', 'Posting Microsoft subscription payload', {
|
|
notificationUrl: subscription.notificationUrl,
|
|
resource: subscription.resource,
|
|
expirationDateTime: subscription.expirationDateTime,
|
|
clientState: subscription.clientState ? '**masked**' : 'none',
|
|
mailboxBase,
|
|
folder: resolvedFolder,
|
|
});
|
|
|
|
const response = await this.httpClient.post('/subscriptions', subscription);
|
|
|
|
// Update config with subscription ID
|
|
this.config.webhook_subscription_id = response.data.id;
|
|
this.config.webhook_expires_at = response.data.expirationDateTime;
|
|
|
|
// Persist webhook details only in microsoft vendor config
|
|
try {
|
|
const knex = await getAdminConnection();
|
|
await knex('microsoft_email_provider_config')
|
|
.where('email_provider_id', this.config.id)
|
|
.andWhere('tenant', this.config.tenant)
|
|
.update({
|
|
webhook_subscription_id: response.data.id,
|
|
webhook_expires_at: response.data.expirationDateTime,
|
|
webhook_verification_token: this.config.webhook_verification_token || null,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
} catch (dbErr: any) {
|
|
this.log('warn', `Failed to persist Microsoft webhook subscription: ${dbErr?.message}`);
|
|
}
|
|
|
|
this.log('info', `Webhook subscription created: ${response.data.id}`);
|
|
|
|
// Return success with subscription id
|
|
return { success: true, subscriptionId: response.data.id };
|
|
} catch (error) {
|
|
// Enrich/log details (status, request-id, body) before throwing
|
|
const enriched = this.handleError(error, 'initializeWebhook');
|
|
this.log('error', 'Subscription creation failed (initializeWebhook)', {
|
|
message: enriched.message,
|
|
context: 'initializeWebhook',
|
|
status: (enriched as any).status,
|
|
code: (enriched as any).code,
|
|
requestId: (enriched as any).requestId,
|
|
responseBody: (enriched as any).responseBody,
|
|
});
|
|
// Return error info instead of throwing to satisfy return type
|
|
return { success: false, error: enriched.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process webhook notification from Microsoft Graph
|
|
*/
|
|
async processWebhookNotification(payload: any): Promise<string[]> {
|
|
try {
|
|
const messageIds: string[] = [];
|
|
|
|
if (payload.value && Array.isArray(payload.value)) {
|
|
for (const notification of payload.value) {
|
|
if (notification.changeType === 'created' && notification.resourceData) {
|
|
messageIds.push(notification.resourceData.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.log('info', `Processed webhook notification with ${messageIds.length} messages`);
|
|
return messageIds;
|
|
} catch (error) {
|
|
throw this.handleError(error, 'processWebhookNotification');
|
|
}
|
|
}
|
|
}
|