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
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
import { getAdminConnection } from '../../db/admin';
|
|
import { EmailProviderConfig } from '../../interfaces/inbound-email.interfaces';
|
|
import { MicrosoftGraphAdapter } from './providers/MicrosoftGraphAdapter';
|
|
import logger from '../../core/logger';
|
|
|
|
interface RenewalOptions {
|
|
tenantId?: string;
|
|
providerId?: string;
|
|
lookAheadMinutes?: number;
|
|
}
|
|
|
|
interface RenewalResult {
|
|
providerId: string;
|
|
tenant: string;
|
|
success: boolean;
|
|
action: 'renewed' | 'recreated' | 'failed' | 'skipped';
|
|
newExpiration?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export class EmailWebhookMaintenanceService {
|
|
|
|
constructor() {
|
|
// No setup needed for logger
|
|
}
|
|
|
|
/**
|
|
* Main entry point to renew Microsoft webhook subscriptions
|
|
*/
|
|
async renewMicrosoftWebhooks(options: RenewalOptions = {}): Promise<RenewalResult[]> {
|
|
const { tenantId, providerId, lookAheadMinutes = 1440 } = options;
|
|
logger.info('Starting Microsoft webhook renewal check', { tenantId, providerId, lookAheadMinutes });
|
|
|
|
try {
|
|
const candidates = await this.findRenewalCandidates(lookAheadMinutes, tenantId, providerId);
|
|
logger.info(`Found ${candidates.length} renewal candidates`);
|
|
|
|
const results: RenewalResult[] = [];
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const result = await this.processCandidate(candidate);
|
|
results.push(result);
|
|
} catch (error: any) {
|
|
logger.error(`Unexpected error processing provider ${candidate.id}`, error);
|
|
results.push({
|
|
providerId: candidate.id,
|
|
tenant: candidate.tenant,
|
|
success: false,
|
|
action: 'failed',
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
} catch (error: any) {
|
|
logger.error('Failed to execute renewal cycle', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find providers that need renewal
|
|
*/
|
|
private async findRenewalCandidates(lookAheadMinutes: number, tenantId?: string, providerId?: string): Promise<EmailProviderConfig[]> {
|
|
const knex = await getAdminConnection();
|
|
const now = new Date();
|
|
const threshold = new Date(now.getTime() + lookAheadMinutes * 60000);
|
|
|
|
let query = knex('email_providers as ep')
|
|
.join('microsoft_email_provider_config as mpc', 'ep.id', 'mpc.email_provider_id')
|
|
.where('ep.provider_type', 'microsoft')
|
|
.andWhere('ep.is_active', true);
|
|
|
|
// If providerId is specified, we ignore expiration/missing subscription logic and force check/renew
|
|
if (!providerId) {
|
|
query = query.andWhere(function() {
|
|
this.whereNull('mpc.webhook_expires_at')
|
|
.orWhere('mpc.webhook_expires_at', '<=', threshold.toISOString())
|
|
.orWhereNull('mpc.webhook_subscription_id');
|
|
});
|
|
}
|
|
|
|
if (tenantId) {
|
|
query = query.andWhere('ep.tenant', tenantId);
|
|
}
|
|
|
|
if (providerId) {
|
|
query = query.andWhere('ep.id', providerId);
|
|
}
|
|
|
|
// Select all columns needed to construct EmailProviderConfig
|
|
const rows = await query.select(
|
|
'ep.id',
|
|
'ep.tenant',
|
|
'ep.provider_name',
|
|
'ep.provider_type',
|
|
'ep.mailbox',
|
|
'ep.is_active',
|
|
'ep.status',
|
|
'ep.last_sync_at',
|
|
'ep.error_message',
|
|
'ep.created_at',
|
|
'ep.updated_at',
|
|
'mpc.webhook_subscription_id',
|
|
'mpc.webhook_verification_token',
|
|
'mpc.webhook_expires_at',
|
|
'mpc.last_subscription_renewal',
|
|
// Select all vendor config columns as an object if possible, or spread them
|
|
// For simplicity, we'll select the raw columns and map them manually as needed by the adapter
|
|
'mpc.client_id',
|
|
'mpc.client_secret',
|
|
'mpc.tenant_id',
|
|
'mpc.access_token',
|
|
'mpc.refresh_token',
|
|
'mpc.token_expires_at'
|
|
);
|
|
|
|
return rows.map(row => this.mapRowToConfig(row));
|
|
}
|
|
|
|
/**
|
|
* Process a single renewal candidate
|
|
*/
|
|
private async processCandidate(config: EmailProviderConfig): Promise<RenewalResult> {
|
|
logger.info(`Processing renewal for ${config.name} (${config.id})`, {
|
|
tenant: config.tenant,
|
|
currentExpiry: config.webhook_expires_at
|
|
});
|
|
|
|
const adapter = new MicrosoftGraphAdapter(config);
|
|
|
|
try {
|
|
// Case 1: No subscription ID -> Register new
|
|
if (!config.webhook_subscription_id) {
|
|
logger.info(`No subscription ID for ${config.id}, registering new webhook`);
|
|
return await this.recreateSubscription(adapter, config);
|
|
}
|
|
|
|
// Case 2: Has subscription ID -> Try to renew
|
|
try {
|
|
await adapter.renewWebhookSubscription();
|
|
|
|
await this.updateHealthStatus(config.id, config.tenant, {
|
|
subscription_status: 'healthy',
|
|
last_renewal_result: 'success',
|
|
failure_reason: null
|
|
});
|
|
|
|
return {
|
|
providerId: config.id,
|
|
tenant: config.tenant,
|
|
success: true,
|
|
action: 'renewed',
|
|
newExpiration: config.webhook_expires_at // adapter updates the config object in place
|
|
};
|
|
} catch (error: any) {
|
|
// check for 404 ResourceNotFound
|
|
if (this.isResourceNotFoundError(error)) {
|
|
logger.warn(`Subscription not found (404) for ${config.id}, attempting to recreate`);
|
|
return await this.recreateSubscription(adapter, config);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
} catch (error: any) {
|
|
logger.error(`Failed to renew/recreate subscription for ${config.id}`, error);
|
|
|
|
await this.updateHealthStatus(config.id, config.tenant, {
|
|
subscription_status: 'error',
|
|
last_renewal_result: 'failure',
|
|
failure_reason: error.message
|
|
});
|
|
|
|
return {
|
|
providerId: config.id,
|
|
tenant: config.tenant,
|
|
success: false,
|
|
action: 'failed',
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recreate a missing or expired subscription
|
|
*/
|
|
private async recreateSubscription(adapter: MicrosoftGraphAdapter, config: EmailProviderConfig): Promise<RenewalResult> {
|
|
// We need the webhook URL. The config object should have it populated from the DB row.
|
|
// If not, we might need to regenerate it, but for now let's assume it's in the DB.
|
|
if (!config.webhook_notification_url) {
|
|
throw new Error('Cannot recreate subscription: webhook_notification_url is missing in config');
|
|
}
|
|
|
|
// Validate URL before attempting registration
|
|
if (config.webhook_notification_url.includes('localhost') || !config.webhook_notification_url.startsWith('https://')) {
|
|
const msg = `Invalid webhook URL detected: ${config.webhook_notification_url}. Microsoft requires a public HTTPS endpoint. Check APPLICATION_URL env var.`;
|
|
logger.error(msg, { providerId: config.id, tenant: config.tenant });
|
|
throw new Error(msg);
|
|
}
|
|
|
|
logger.info(`Attempting to recreate subscription with URL: ${config.webhook_notification_url}`, {
|
|
providerId: config.id,
|
|
tenant: config.tenant
|
|
});
|
|
|
|
const result = await adapter.initializeWebhook(config.webhook_notification_url);
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Failed to initialize webhook');
|
|
}
|
|
|
|
await this.updateHealthStatus(config.id, config.tenant, {
|
|
subscription_status: 'healthy',
|
|
last_renewal_result: 'success',
|
|
failure_reason: null
|
|
});
|
|
|
|
return {
|
|
providerId: config.id,
|
|
tenant: config.tenant,
|
|
success: true,
|
|
action: 'recreated',
|
|
newExpiration: adapter.getConfig().webhook_expires_at
|
|
};
|
|
}
|
|
|
|
private isResourceNotFoundError(error: any): boolean {
|
|
// Check for Graph API 404 structure
|
|
if (error?.response?.status === 404) return true;
|
|
if (error?.code === 'ResourceNotFound') return true;
|
|
if (error?.message?.includes('ResourceNotFound')) return true;
|
|
if (error?.message?.includes('Subscription not found')) return true;
|
|
return false;
|
|
}
|
|
|
|
private async updateHealthStatus(providerId: string, tenant: string, status: {
|
|
subscription_status: string;
|
|
last_renewal_result: string;
|
|
failure_reason: string | null;
|
|
}): Promise<void> {
|
|
try {
|
|
const knex = await getAdminConnection();
|
|
const now = new Date().toISOString();
|
|
|
|
// Check if health row exists, if not create it
|
|
const existing = await knex('email_provider_health')
|
|
.where('provider_id', providerId)
|
|
.first();
|
|
|
|
if (existing) {
|
|
await knex('email_provider_health')
|
|
.where('provider_id', providerId)
|
|
.update({
|
|
...status,
|
|
last_renewal_attempt_at: now,
|
|
updated_at: now
|
|
});
|
|
} else {
|
|
await knex('email_provider_health')
|
|
.insert({
|
|
provider_id: providerId,
|
|
tenant: tenant, // Fixed: column name is 'tenant', not 'tenant_id'
|
|
provider_type: 'microsoft', // We know it's microsoft here
|
|
...status,
|
|
last_renewal_attempt_at: now,
|
|
created_at: now,
|
|
updated_at: now
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`Failed to update health status for ${providerId}`, error);
|
|
}
|
|
}
|
|
|
|
private getBaseUrl(): string {
|
|
const envApplicationUrl = process.env.APPLICATION_URL ||
|
|
process.env.NEXTAUTH_URL ||
|
|
process.env.NEXT_PUBLIC_BASE_URL;
|
|
|
|
if (!envApplicationUrl || envApplicationUrl === 'www.algapsa.com') {
|
|
return 'https://algapsa.com';
|
|
}
|
|
|
|
// Ensure it starts with https:// if it's a non-localhost URL, or return as is if already a valid URL
|
|
if (envApplicationUrl.startsWith('http://localhost') || envApplicationUrl.startsWith('https://')) {
|
|
return envApplicationUrl;
|
|
} else {
|
|
return `https://${envApplicationUrl}`;
|
|
}
|
|
}
|
|
|
|
private mapRowToConfig(row: any): EmailProviderConfig {
|
|
const baseUrl = this.getBaseUrl();
|
|
const webhookPath = row.provider_type === 'microsoft'
|
|
? '/api/email/webhooks/microsoft'
|
|
: '/api/email/webhooks/google';
|
|
|
|
return {
|
|
id: row.id,
|
|
tenant: row.tenant,
|
|
name: row.provider_name,
|
|
provider_type: row.provider_type || 'microsoft',
|
|
mailbox: row.mailbox,
|
|
folder_to_monitor: 'Inbox', // Default
|
|
active: row.is_active,
|
|
webhook_notification_url: `${baseUrl}${webhookPath}`,
|
|
webhook_subscription_id: row.webhook_subscription_id,
|
|
webhook_verification_token: row.webhook_verification_token,
|
|
webhook_expires_at: row.webhook_expires_at,
|
|
last_subscription_renewal: row.last_subscription_renewal,
|
|
connection_status: row.status || 'connected',
|
|
last_connection_test: row.last_sync_at,
|
|
connection_error_message: row.error_message,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
provider_config: {
|
|
client_id: row.client_id,
|
|
client_secret: row.client_secret,
|
|
tenantId: row.tenant_id,
|
|
access_token: row.access_token,
|
|
refresh_token: row.refresh_token,
|
|
token_expires_at: row.token_expires_at,
|
|
}
|
|
};
|
|
}
|
|
} |