This commit is contained in:
neo773
2026-05-07 15:25:04 +05:30
parent 0fe1eca010
commit b0553677c7
15 changed files with 65 additions and 738 deletions
@@ -69,8 +69,6 @@ export class AddInboundWebhookSubscription1778500000000
`DROP INDEX "core"."IDX_INBOUND_WEBHOOK_SUBSCRIPTION_WORKSPACE_ID"`,
);
await queryRunner.query(
`DROP TABLE "core"."inboundWebhookSubscription"`,
);
await queryRunner.query(`DROP TABLE "core"."inboundWebhookSubscription"`);
}
}
@@ -19,10 +19,7 @@ import { InboundWebhookDispatcherService } from 'src/engine/core-modules/inbound
import { InboundWebhookIdempotencyService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-idempotency.service';
import { ProcessInboundWebhookJob } from 'src/engine/core-modules/inbound-webhook/jobs/process-inbound-webhook.job';
import { type InboundWebhookJobData } from 'src/engine/core-modules/inbound-webhook/dtos/inbound-webhook-job-data.type';
import {
type InboundWebhookSource,
isInboundWebhookSource,
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-source.type';
import { type InboundWebhookSource } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-source.type';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -54,13 +51,6 @@ export class InboundWebhookController {
);
}
if (!isInboundWebhookSource(sourceParam)) {
throw new InboundWebhookException(
`Inbound webhook source ${sourceParam} is not supported`,
InboundWebhookExceptionCode.SOURCE_NOT_SUPPORTED,
);
}
const source: InboundWebhookSource = sourceParam;
const handler = this.dispatcher.resolve(source);
@@ -1,140 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InboundWebhookSubscriptionService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-subscription.service';
import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
import {
type InboundWebhookEnvelope,
type InboundWebhookRequest,
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
import { type SubscribableInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import {
CalendarEventListFetchJob,
type CalendarEventListFetchJobData,
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
@Injectable()
export class GoogleCalendarInboundWebhookHandler
implements SubscribableInboundWebhookHandler
{
private readonly logger = new Logger(GoogleCalendarInboundWebhookHandler.name);
constructor(
private readonly subscriptionService: InboundWebhookSubscriptionService,
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly calendarQueue: MessageQueueService,
) {}
// Google Calendar push uses the X-Goog-Channel-Token header as a shared
// secret declared at watch-creation time. Compare against the subscription
// secret looked up by X-Goog-Channel-ID.
async verify(request: InboundWebhookRequest): Promise<boolean> {
const channelId = this.headerValue(request, 'x-goog-channel-id');
const channelToken = this.headerValue(request, 'x-goog-channel-token');
if (channelId === null || channelToken === null) {
return false;
}
const subscription =
await this.subscriptionService.findByExternalSubscriptionId({
source: 'google-calendar',
externalSubscriptionId: channelId,
});
return subscription !== null && subscription.secret === channelToken;
}
async buildEnvelope(
request: InboundWebhookRequest,
): Promise<InboundWebhookEnvelope> {
const channelId = this.headerValue(request, 'x-goog-channel-id') ?? '';
const messageNumber =
this.headerValue(request, 'x-goog-message-number') ?? '';
const subscription =
await this.subscriptionService.findByExternalSubscriptionId({
source: 'google-calendar',
externalSubscriptionId: channelId,
});
return {
source: 'google-calendar',
externalEventId: `${channelId}:${messageNumber}`,
workspaceId: subscription?.workspaceId ?? null,
subscriptionId: subscription?.id ?? null,
payload: {
channelId,
messageNumber,
resourceState: this.headerValue(request, 'x-goog-resource-state'),
},
headers: request.headers as Record<string, string | string[] | undefined>,
};
}
async handle(envelope: InboundWebhookEnvelope): Promise<void> {
if (envelope.subscriptionId === null) {
return;
}
const subscription = await this.subscriptionService.findById(
envelope.subscriptionId,
);
if (subscription === null) {
return;
}
await this.subscriptionService.markNotified(subscription.id);
const calendarChannelId = this.resolveCalendarChannelId(subscription);
if (calendarChannelId === null) {
return;
}
const jobData: CalendarEventListFetchJobData = {
calendarChannelId,
workspaceId: subscription.workspaceId,
};
await this.calendarQueue.add<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
jobData,
);
}
async renewSubscription(
_subscription: InboundWebhookSubscriptionEntity,
): Promise<void> {
this.logger.log(
'Google calendar subscription renewal hook — implementation pending driver migration',
);
}
private headerValue(
request: InboundWebhookRequest,
name: string,
): string | null {
const value = request.headers[name];
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0];
}
return null;
}
private resolveCalendarChannelId(
subscription: InboundWebhookSubscriptionEntity,
): string | null {
const calendarChannelId = subscription.metadata?.['calendarChannelId'];
return typeof calendarChannelId === 'string' ? calendarChannelId : null;
}
}
@@ -1,157 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InboundWebhookSubscriptionService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-subscription.service';
import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
import {
type InboundWebhookEnvelope,
type InboundWebhookRequest,
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
import { type SubscribableInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import {
MessagingMessageListFetchJob,
type MessagingMessageListFetchJobData,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
type PubSubPushBody = {
message?: {
data?: string;
messageId?: string;
publishTime?: string;
};
subscription?: string;
};
type GmailHistoryNotification = {
emailAddress?: string;
historyId?: number | string;
};
@Injectable()
export class GoogleMessagingInboundWebhookHandler
implements SubscribableInboundWebhookHandler
{
private readonly logger = new Logger(GoogleMessagingInboundWebhookHandler.name);
constructor(
private readonly subscriptionService: InboundWebhookSubscriptionService,
@InjectMessageQueue(MessageQueue.messagingQueue)
private readonly messagingQueue: MessageQueueService,
) {}
// Google Pub/Sub push delivers a JWT in the Authorization header signed by
// Google. Production verification fetches Google's public keys + checks
// audience. Out of scope for this PR — wired in when the driver migration
// PR enables push for live workspaces.
async verify(_request: InboundWebhookRequest): Promise<boolean> {
return true;
}
async buildEnvelope(
request: InboundWebhookRequest,
): Promise<InboundWebhookEnvelope> {
const body = (request.body ?? {}) as PubSubPushBody;
const messageId = body.message?.messageId ?? '';
const decoded = this.decodePubSubData(body.message?.data);
const subscription = decoded.emailAddress
? await this.findSubscriptionByEmail(decoded.emailAddress)
: null;
return {
source: 'google-messaging',
externalEventId: messageId,
workspaceId: subscription?.workspaceId ?? null,
subscriptionId: subscription?.id ?? null,
payload: { decoded, raw: body },
headers: request.headers as Record<string, string | string[] | undefined>,
};
}
async handle(envelope: InboundWebhookEnvelope): Promise<void> {
if (envelope.subscriptionId === null) {
this.logger.warn(
'Received Google messaging push for unknown subscription — dropping',
);
return;
}
const subscription = await this.subscriptionService.findById(
envelope.subscriptionId,
);
if (
subscription === null ||
subscription.connectedAccountId === null ||
subscription.workspaceId === null
) {
return;
}
await this.subscriptionService.markNotified(subscription.id);
const messageChannelId = this.resolveMessageChannelId(subscription);
if (messageChannelId === null) {
return;
}
const jobData: MessagingMessageListFetchJobData = {
messageChannelId,
workspaceId: subscription.workspaceId,
};
await this.messagingQueue.add<MessagingMessageListFetchJobData>(
MessagingMessageListFetchJob.name,
jobData,
);
}
// Gmail watch expires after 7 days and must be re-issued. Concrete Gmail
// API call lives in the driver layer — added in the driver migration PR.
async renewSubscription(
_subscription: InboundWebhookSubscriptionEntity,
): Promise<void> {
this.logger.log(
'Google messaging subscription renewal hook — implementation pending driver migration',
);
}
private decodePubSubData(data?: string): GmailHistoryNotification {
if (data === undefined) {
return {};
}
try {
const parsed: unknown = JSON.parse(
Buffer.from(data, 'base64').toString('utf-8'),
);
if (typeof parsed === 'object' && parsed !== null) {
return parsed as GmailHistoryNotification;
}
return {};
} catch {
return {};
}
}
private async findSubscriptionByEmail(
_emailAddress: string,
): Promise<InboundWebhookSubscriptionEntity | null> {
// Lookup by metadata.emailAddress — populated when the driver migration
// PR creates the subscription during message-channel onboarding.
return null;
}
private resolveMessageChannelId(
subscription: InboundWebhookSubscriptionEntity,
): string | null {
const messageChannelId = subscription.metadata?.['messageChannelId'];
return typeof messageChannelId === 'string' ? messageChannelId : null;
}
}
@@ -1,57 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import {
type InboundWebhookEnvelope,
type InboundWebhookRequest,
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
import { type InboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
type SesSnsMessage = {
Type?: string;
MessageId?: string;
TopicArn?: string;
Message?: string;
Signature?: string;
SignatureVersion?: string;
SigningCertURL?: string;
};
@Injectable()
export class InboundEmailSesInboundWebhookHandler
implements InboundWebhookHandler
{
private readonly logger = new Logger(
InboundEmailSesInboundWebhookHandler.name,
);
// SES delivers via SNS. Production verification fetches the SNS signing
// certificate and validates the signature over the canonicalized message.
// Out of scope for this PR — wired in when inbound email aliases ship.
async verify(_request: InboundWebhookRequest): Promise<boolean> {
return true;
}
async buildEnvelope(
request: InboundWebhookRequest,
): Promise<InboundWebhookEnvelope> {
const body = (request.body ?? {}) as SesSnsMessage;
return {
source: 'inbound-email-ses',
externalEventId: body.MessageId ?? `ses:${Date.now()}`,
workspaceId: null,
subscriptionId: null,
payload: body,
headers: request.headers as Record<string, string | string[] | undefined>,
};
}
async handle(_envelope: InboundWebhookEnvelope): Promise<void> {
// EML parsing + participant matching + message ingestion live here once
// the inbound-email-alias feature is built. Body of work is heavy
// (mailparser + S3 fetch) and runs entirely on the worker pod.
this.logger.log(
'SES inbound email handler — implementation pending inbound-email-alias feature',
);
}
}
@@ -1,153 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InboundWebhookSubscriptionService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-subscription.service';
import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
import {
type InboundWebhookEnvelope,
type InboundWebhookRequest,
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
import { type SubscribableInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import {
CalendarEventListFetchJob,
type CalendarEventListFetchJobData,
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
type GraphChangeNotification = {
subscriptionId?: string;
clientState?: string;
resourceData?: { id?: string };
};
type GraphNotificationBatch = {
value?: GraphChangeNotification[];
};
@Injectable()
export class MicrosoftCalendarInboundWebhookHandler
implements SubscribableInboundWebhookHandler
{
private readonly logger = new Logger(
MicrosoftCalendarInboundWebhookHandler.name,
);
constructor(
private readonly subscriptionService: InboundWebhookSubscriptionService,
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly calendarQueue: MessageQueueService,
) {}
async verify(request: InboundWebhookRequest): Promise<boolean> {
const body = (request.body ?? {}) as GraphNotificationBatch;
const notifications = body.value ?? [];
if (notifications.length === 0) {
return false;
}
for (const notification of notifications) {
const externalSubscriptionId = notification.subscriptionId;
const clientState = notification.clientState;
if (
externalSubscriptionId === undefined ||
clientState === undefined
) {
return false;
}
const subscription =
await this.subscriptionService.findByExternalSubscriptionId({
source: 'microsoft-calendar',
externalSubscriptionId,
});
if (subscription === null || subscription.secret !== clientState) {
return false;
}
}
return true;
}
async buildEnvelope(
request: InboundWebhookRequest,
): Promise<InboundWebhookEnvelope> {
const body = (request.body ?? {}) as GraphNotificationBatch;
const first = body.value?.[0];
const subscription =
first?.subscriptionId !== undefined
? await this.subscriptionService.findByExternalSubscriptionId({
source: 'microsoft-calendar',
externalSubscriptionId: first.subscriptionId,
})
: null;
return {
source: 'microsoft-calendar',
externalEventId: this.computeEventId(body),
workspaceId: subscription?.workspaceId ?? null,
subscriptionId: subscription?.id ?? null,
payload: body,
headers: request.headers as Record<string, string | string[] | undefined>,
};
}
async handle(envelope: InboundWebhookEnvelope): Promise<void> {
if (envelope.subscriptionId === null) {
return;
}
const subscription = await this.subscriptionService.findById(
envelope.subscriptionId,
);
if (subscription === null) {
return;
}
await this.subscriptionService.markNotified(subscription.id);
const calendarChannelId = this.resolveCalendarChannelId(subscription);
if (calendarChannelId === null) {
return;
}
const jobData: CalendarEventListFetchJobData = {
calendarChannelId,
workspaceId: subscription.workspaceId,
};
await this.calendarQueue.add<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
jobData,
);
}
async renewSubscription(
_subscription: InboundWebhookSubscriptionEntity,
): Promise<void> {
this.logger.log(
'Microsoft calendar subscription renewal hook — implementation pending driver migration',
);
}
private computeEventId(body: GraphNotificationBatch): string {
const ids = (body.value ?? [])
.map((notification) => notification.resourceData?.id)
.filter((id): id is string => typeof id === 'string');
return ids.length > 0 ? ids.join(':') : `graph:${Date.now()}`;
}
private resolveCalendarChannelId(
subscription: InboundWebhookSubscriptionEntity,
): string | null {
const calendarChannelId = subscription.metadata?.['calendarChannelId'];
return typeof calendarChannelId === 'string' ? calendarChannelId : null;
}
}
@@ -1,157 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InboundWebhookSubscriptionService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-subscription.service';
import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
import {
type InboundWebhookEnvelope,
type InboundWebhookRequest,
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
import { type SubscribableInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import {
MessagingMessageListFetchJob,
type MessagingMessageListFetchJobData,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
type GraphChangeNotification = {
subscriptionId?: string;
clientState?: string;
resource?: string;
resourceData?: { id?: string };
};
type GraphNotificationBatch = {
value?: GraphChangeNotification[];
};
@Injectable()
export class MicrosoftMessagingInboundWebhookHandler
implements SubscribableInboundWebhookHandler
{
private readonly logger = new Logger(
MicrosoftMessagingInboundWebhookHandler.name,
);
constructor(
private readonly subscriptionService: InboundWebhookSubscriptionService,
@InjectMessageQueue(MessageQueue.messagingQueue)
private readonly messagingQueue: MessageQueueService,
) {}
// Microsoft Graph echoes back the clientState set when the subscription
// was created. Looking up by subscriptionId + comparing clientState is
// the documented verify pattern.
async verify(request: InboundWebhookRequest): Promise<boolean> {
const body = (request.body ?? {}) as GraphNotificationBatch;
const notifications = body.value ?? [];
if (notifications.length === 0) {
return false;
}
for (const notification of notifications) {
const externalSubscriptionId = notification.subscriptionId;
const clientState = notification.clientState;
if (
externalSubscriptionId === undefined ||
clientState === undefined
) {
return false;
}
const subscription =
await this.subscriptionService.findByExternalSubscriptionId({
source: 'microsoft-messaging',
externalSubscriptionId,
});
if (subscription === null || subscription.secret !== clientState) {
return false;
}
}
return true;
}
async buildEnvelope(
request: InboundWebhookRequest,
): Promise<InboundWebhookEnvelope> {
const body = (request.body ?? {}) as GraphNotificationBatch;
const first = body.value?.[0];
const subscription =
first?.subscriptionId !== undefined
? await this.subscriptionService.findByExternalSubscriptionId({
source: 'microsoft-messaging',
externalSubscriptionId: first.subscriptionId,
})
: null;
return {
source: 'microsoft-messaging',
externalEventId: this.computeEventId(body),
workspaceId: subscription?.workspaceId ?? null,
subscriptionId: subscription?.id ?? null,
payload: body,
headers: request.headers as Record<string, string | string[] | undefined>,
};
}
async handle(envelope: InboundWebhookEnvelope): Promise<void> {
if (envelope.subscriptionId === null) {
return;
}
const subscription = await this.subscriptionService.findById(
envelope.subscriptionId,
);
if (subscription === null) {
return;
}
await this.subscriptionService.markNotified(subscription.id);
const messageChannelId = this.resolveMessageChannelId(subscription);
if (messageChannelId === null) {
return;
}
const jobData: MessagingMessageListFetchJobData = {
messageChannelId,
workspaceId: subscription.workspaceId,
};
await this.messagingQueue.add<MessagingMessageListFetchJobData>(
MessagingMessageListFetchJob.name,
jobData,
);
}
async renewSubscription(
_subscription: InboundWebhookSubscriptionEntity,
): Promise<void> {
this.logger.log(
'Microsoft messaging subscription renewal hook — implementation pending driver migration',
);
}
private computeEventId(body: GraphNotificationBatch): string {
const ids = (body.value ?? [])
.map((notification) => notification.resourceData?.id)
.filter((id): id is string => typeof id === 'string');
return ids.length > 0 ? ids.join(':') : `graph:${Date.now()}`;
}
private resolveMessageChannelId(
subscription: InboundWebhookSubscriptionEntity,
): string | null {
const messageChannelId = subscription.metadata?.['messageChannelId'];
return typeof messageChannelId === 'string' ? messageChannelId : null;
}
}
@@ -1,3 +1,7 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum InboundWebhookExceptionCode {
@@ -9,4 +13,37 @@ export enum InboundWebhookExceptionCode {
DUPLICATE_EVENT = 'DUPLICATE_EVENT',
}
export class InboundWebhookException extends CustomException<InboundWebhookExceptionCode> {}
const getInboundWebhookExceptionUserFriendlyMessage = (
code: InboundWebhookExceptionCode,
): MessageDescriptor => {
switch (code) {
case InboundWebhookExceptionCode.SOURCE_NOT_SUPPORTED:
return msg`Inbound webhook source is not supported.`;
case InboundWebhookExceptionCode.INVALID_SIGNATURE:
return msg`Inbound webhook signature is invalid.`;
case InboundWebhookExceptionCode.MISSING_RAW_BODY:
return msg`Inbound webhook request is missing a raw body.`;
case InboundWebhookExceptionCode.SUBSCRIPTION_NOT_FOUND:
return msg`Inbound webhook subscription was not found.`;
case InboundWebhookExceptionCode.REPLAY_WINDOW_EXCEEDED:
return msg`Inbound webhook timestamp is outside the accepted replay window.`;
case InboundWebhookExceptionCode.DUPLICATE_EVENT:
return msg`Inbound webhook event was already processed.`;
default:
assertUnreachable(code);
}
};
export class InboundWebhookException extends CustomException<InboundWebhookExceptionCode> {
constructor(
message: string,
code: InboundWebhookExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getInboundWebhookExceptionUserFriendlyMessage(code),
});
}
}
@@ -5,11 +5,6 @@ import { InboundWebhookRenewalCronCommand } from 'src/engine/core-modules/inboun
import { InboundWebhookRenewalCronJob } from 'src/engine/core-modules/inbound-webhook/crons/jobs/inbound-webhook-renewal.cron.job';
import { InboundWebhookController } from 'src/engine/core-modules/inbound-webhook/controllers/inbound-webhook.controller';
import { InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
import { GoogleCalendarInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/google-calendar-inbound-webhook.handler';
import { GoogleMessagingInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/google-messaging-inbound-webhook.handler';
import { InboundEmailSesInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/inbound-email-ses-inbound-webhook.handler';
import { MicrosoftCalendarInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/microsoft-calendar-inbound-webhook.handler';
import { MicrosoftMessagingInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/microsoft-messaging-inbound-webhook.handler';
import { ProcessInboundWebhookJob } from 'src/engine/core-modules/inbound-webhook/jobs/process-inbound-webhook.job';
import { InboundWebhookDispatcherService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-dispatcher.service';
import { InboundWebhookIdempotencyService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-idempotency.service';
@@ -26,15 +21,14 @@ import { InboundWebhookVerifyService } from 'src/engine/core-modules/inbound-web
InboundWebhookIdempotencyService,
InboundWebhookSubscriptionService,
InboundWebhookVerifyService,
GoogleMessagingInboundWebhookHandler,
GoogleCalendarInboundWebhookHandler,
MicrosoftMessagingInboundWebhookHandler,
MicrosoftCalendarInboundWebhookHandler,
InboundEmailSesInboundWebhookHandler,
ProcessInboundWebhookJob,
InboundWebhookRenewalCronJob,
InboundWebhookRenewalCronCommand,
],
exports: [InboundWebhookSubscriptionService],
exports: [
InboundWebhookDispatcherService,
InboundWebhookSubscriptionService,
InboundWebhookVerifyService,
],
})
export class InboundWebhookModule {}
@@ -11,9 +11,7 @@ import { type InboundWebhookJobData } from 'src/engine/core-modules/inbound-webh
export class ProcessInboundWebhookJob {
private readonly logger = new Logger(ProcessInboundWebhookJob.name);
constructor(
private readonly dispatcher: InboundWebhookDispatcherService,
) {}
constructor(private readonly dispatcher: InboundWebhookDispatcherService) {}
@Process(ProcessInboundWebhookJob.name)
async handle(data: InboundWebhookJobData): Promise<void> {
@@ -1,10 +1,5 @@
import { Injectable } from '@nestjs/common';
import { GoogleCalendarInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/google-calendar-inbound-webhook.handler';
import { GoogleMessagingInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/google-messaging-inbound-webhook.handler';
import { InboundEmailSesInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/inbound-email-ses-inbound-webhook.handler';
import { MicrosoftCalendarInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/microsoft-calendar-inbound-webhook.handler';
import { MicrosoftMessagingInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/microsoft-messaging-inbound-webhook.handler';
import {
InboundWebhookException,
InboundWebhookExceptionCode,
@@ -16,36 +11,20 @@ import {
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
import { type InboundWebhookSource } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-source.type';
// Switch dispatcher (mirrors MessagingGetMessageListService and the Stripe
// webhook handler in billing-webhook). Concrete sources are added by each
// consumer PR — driver migration, inbound email, etc. — by injecting their
// handler class and adding a case below.
@Injectable()
export class InboundWebhookDispatcherService {
constructor(
private readonly googleMessagingHandler: GoogleMessagingInboundWebhookHandler,
private readonly googleCalendarHandler: GoogleCalendarInboundWebhookHandler,
private readonly microsoftMessagingHandler: MicrosoftMessagingInboundWebhookHandler,
private readonly microsoftCalendarHandler: MicrosoftCalendarInboundWebhookHandler,
private readonly inboundEmailSesHandler: InboundEmailSesInboundWebhookHandler,
) {}
resolve(source: InboundWebhookSource): InboundWebhookHandler {
switch (source) {
case 'google-messaging':
return this.googleMessagingHandler;
case 'google-calendar':
return this.googleCalendarHandler;
case 'microsoft-messaging':
return this.microsoftMessagingHandler;
case 'microsoft-calendar':
return this.microsoftCalendarHandler;
case 'inbound-email-ses':
return this.inboundEmailSesHandler;
default: {
const _exhaustive: never = source;
// Per-source handlers registered in follow-up PRs.
default:
throw new InboundWebhookException(
`Inbound webhook source ${_exhaustive} is not supported`,
`Inbound webhook source ${source} is not supported`,
InboundWebhookExceptionCode.SOURCE_NOT_SUPPORTED,
);
}
}
}
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InboundWebhookException, InboundWebhookExceptionCode } from 'src/engine/core-modules/inbound-webhook/inbound-webhook.exception';
import {
InboundWebhookException,
InboundWebhookExceptionCode,
} from 'src/engine/core-modules/inbound-webhook/inbound-webhook.exception';
import { INBOUND_WEBHOOK_REPLAY_WINDOW_MS } from 'src/engine/core-modules/inbound-webhook/inbound-webhook.constants';
import {
type HmacAlgorithm,
@@ -10,7 +10,9 @@ import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/i
// belongs.
export type InboundWebhookHandler = {
verify(request: InboundWebhookRequest): Promise<boolean>;
buildEnvelope(request: InboundWebhookRequest): Promise<InboundWebhookEnvelope>;
buildEnvelope(
request: InboundWebhookRequest,
): Promise<InboundWebhookEnvelope>;
handle(envelope: InboundWebhookEnvelope): Promise<void>;
};
@@ -1,15 +1,4 @@
export const INBOUND_WEBHOOK_SOURCES = [
'google-messaging',
'google-calendar',
'microsoft-messaging',
'microsoft-calendar',
'inbound-email-ses',
] as const;
export type InboundWebhookSource = (typeof INBOUND_WEBHOOK_SOURCES)[number];
export const isInboundWebhookSource = (
value: unknown,
): value is InboundWebhookSource =>
typeof value === 'string' &&
(INBOUND_WEBHOOK_SOURCES as readonly string[]).includes(value);
// Sources are identified by an opaque string supplied as a URL path segment.
// Concrete values are added by each consumer PR (driver migration, inbound
// email, etc.) when they register an InboundWebhookHandler.
export type InboundWebhookSource = string;
@@ -18,4 +18,5 @@ export const MESSAGE_QUEUE_PRIORITY = {
[MessageQueue.cronQueue]: 7,
[MessageQueue.aiQueue]: 5,
[MessageQueue.aiStreamQueue]: 2,
[MessageQueue.inboundWebhookQueue]: 2,
};