feat(messaging): webhook push sync for Gmail, Calendar and Microsoft (#21970)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21970?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
neo773
2026-06-23 14:04:16 +05:30
committed by GitHub
parent 84f4ac9082
commit 9e31ffdf68
51 changed files with 2079 additions and 6 deletions
@@ -0,0 +1,39 @@
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 ConnectedAccountSyncWebhookExceptionCode {
MISSING_REQUEST_BODY = 'MISSING_REQUEST_BODY',
INVALID_PAYLOAD = 'INVALID_PAYLOAD',
INVALID_SIGNATURE = 'INVALID_SIGNATURE',
}
const getConnectedAccountSyncWebhookExceptionUserFriendlyMessage = (
code: ConnectedAccountSyncWebhookExceptionCode,
) => {
switch (code) {
case ConnectedAccountSyncWebhookExceptionCode.MISSING_REQUEST_BODY:
case ConnectedAccountSyncWebhookExceptionCode.INVALID_PAYLOAD:
return msg`The webhook request could not be processed.`;
case ConnectedAccountSyncWebhookExceptionCode.INVALID_SIGNATURE:
return msg`The webhook request could not be authenticated.`;
default:
assertUnreachable(code);
}
};
export class ConnectedAccountSyncWebhookException extends CustomException<ConnectedAccountSyncWebhookExceptionCode> {
constructor(
message: string,
code: ConnectedAccountSyncWebhookExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getConnectedAccountSyncWebhookExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,106 @@
import {
Body,
Controller,
Headers,
HttpCode,
HttpStatus,
Post,
Query,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { type Response } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { GoogleCalendarNotificationHandler } from 'src/modules/connected-account-sync-webhooks/drivers/google/google-calendar-notification.handler';
import { GoogleMessagingNotificationHandler } from 'src/modules/connected-account-sync-webhooks/drivers/google/google-messaging-notification.handler';
import { MicrosoftCalendarNotificationHandler } from 'src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-calendar-notification.handler';
import { MicrosoftMessagingNotificationHandler } from 'src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-messaging-notification.handler';
import { ConnectedAccountSyncWebhookApiExceptionFilter } from 'src/modules/connected-account-sync-webhooks/filters/connected-account-sync-webhook-api-exception.filter';
import { type GooglePubSubPushMessage } from 'src/modules/connected-account-sync-webhooks/types/google-pubsub-push.type';
import { type MicrosoftGraphNotificationPayload } from 'src/modules/connected-account-sync-webhooks/types/microsoft-graph-notification.type';
@Controller()
@UseFilters(ConnectedAccountSyncWebhookApiExceptionFilter)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
export class ConnectedAccountSyncWebhooksController {
constructor(
private readonly googleMessagingNotificationHandler: GoogleMessagingNotificationHandler,
private readonly googleCalendarNotificationHandler: GoogleCalendarNotificationHandler,
private readonly microsoftMessagingNotificationHandler: MicrosoftMessagingNotificationHandler,
private readonly microsoftCalendarNotificationHandler: MicrosoftCalendarNotificationHandler,
) {}
@Post('webhooks/google/messaging')
@HttpCode(HttpStatus.OK)
async handleGoogleMessaging(
@Body() body: GooglePubSubPushMessage,
@Headers('authorization') authorizationHeader: string | undefined,
): Promise<void> {
await this.googleMessagingNotificationHandler.handle({
body,
authorizationHeader,
});
}
@Post('webhooks/google/calendar')
@HttpCode(HttpStatus.OK)
async handleGoogleCalendar(
@Headers('x-goog-channel-id') channelId: string | undefined,
@Headers('x-goog-resource-state') resourceState: string | undefined,
@Headers('x-goog-channel-token') channelToken: string | undefined,
): Promise<void> {
await this.googleCalendarNotificationHandler.handle({
channelId,
resourceState,
channelToken,
});
}
@Post('webhooks/microsoft/messaging')
@HttpCode(HttpStatus.OK)
async handleMicrosoftMessaging(
@Body() body: MicrosoftGraphNotificationPayload,
@Query('validationToken') validationToken: string | undefined,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
if (isDefined(validationToken)) {
return this.respondToValidationHandshake(validationToken, response);
}
await this.microsoftMessagingNotificationHandler.handle(body.value ?? []);
return '';
}
@Post('webhooks/microsoft/calendar')
@HttpCode(HttpStatus.OK)
async handleMicrosoftCalendar(
@Body() body: MicrosoftGraphNotificationPayload,
@Query('validationToken') validationToken: string | undefined,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
if (isDefined(validationToken)) {
return this.respondToValidationHandshake(validationToken, response);
}
await this.microsoftCalendarNotificationHandler.handle(body.value ?? []);
return '';
}
private respondToValidationHandshake(
validationToken: string,
response: Response,
): string {
response.type('text/plain');
return escapeHtml(validationToken);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ConnectedAccountSyncWebhooksController } from 'src/modules/connected-account-sync-webhooks/connected-account-sync-webhooks.controller';
import { GoogleWebhookDriverModule } from 'src/modules/connected-account-sync-webhooks/drivers/google/google-webhook-driver.module';
import { MicrosoftWebhookDriverModule } from 'src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-webhook-driver.module';
@Module({
imports: [GoogleWebhookDriverModule, MicrosoftWebhookDriverModule],
controllers: [ConnectedAccountSyncWebhooksController],
})
export class ConnectedAccountSyncWebhooksModule {}
@@ -0,0 +1,76 @@
import { timingSafeEqual } from 'crypto';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { WebhookSubscriptionStatus } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { WebhookSyncTriggerService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-sync-trigger.service';
import {
ConnectedAccountSyncWebhookException,
ConnectedAccountSyncWebhookExceptionCode,
} from 'src/modules/connected-account-sync-webhooks/connected-account-sync-webhook.exception';
import { type GoogleCalendarChannelNotification } from 'src/modules/connected-account-sync-webhooks/types/google-calendar-notification.type';
import { type WebhookNotificationHandler } from 'src/modules/connected-account-sync-webhooks/types/webhook-notification-handler.type';
const GOOGLE_CALENDAR_SYNC_RESOURCE_STATE = 'sync';
@Injectable()
export class GoogleCalendarNotificationHandler implements WebhookNotificationHandler<GoogleCalendarChannelNotification> {
constructor(
@InjectRepository(CalendarChannelEntity)
private readonly calendarChannelRepository: Repository<CalendarChannelEntity>,
private readonly webhookSyncTriggerService: WebhookSyncTriggerService,
) {}
async handle(request: GoogleCalendarChannelNotification): Promise<void> {
if (request.resourceState === GOOGLE_CALENDAR_SYNC_RESOURCE_STATE) {
return;
}
if (!isNonEmptyString(request.channelId)) {
return;
}
const calendarChannel = await this.calendarChannelRepository.findOne({
where: {
webhookSubscriptionExternalId: request.channelId,
webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE,
},
});
if (!isDefined(calendarChannel)) {
return;
}
const channelToken = request.channelToken;
const clientState = calendarChannel.webhookSubscriptionClientState;
const channelTokenBuffer = isNonEmptyString(channelToken)
? Buffer.from(channelToken)
: null;
const clientStateBuffer = isNonEmptyString(clientState)
? Buffer.from(clientState)
: null;
if (
!isDefined(channelTokenBuffer) ||
!isDefined(clientStateBuffer) ||
channelTokenBuffer.length !== clientStateBuffer.length ||
!timingSafeEqual(channelTokenBuffer, clientStateBuffer)
) {
throw new ConnectedAccountSyncWebhookException(
'Google calendar channel token mismatch',
ConnectedAccountSyncWebhookExceptionCode.INVALID_SIGNATURE,
);
}
await this.webhookSyncTriggerService.triggerCalendarSync(
calendarChannel.id,
calendarChannel.workspaceId,
);
}
}
@@ -0,0 +1,154 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { OAuth2Client } from 'google-auth-library';
import {
ConnectedAccountProvider,
WebhookSubscriptionStatus,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WebhookSyncTriggerService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-sync-trigger.service';
import {
ConnectedAccountSyncWebhookException,
ConnectedAccountSyncWebhookExceptionCode,
} from 'src/modules/connected-account-sync-webhooks/connected-account-sync-webhook.exception';
import {
type GmailPushDecodedData,
type GooglePubSubPushMessage,
} from 'src/modules/connected-account-sync-webhooks/types/google-pubsub-push.type';
import { type WebhookNotificationHandler } from 'src/modules/connected-account-sync-webhooks/types/webhook-notification-handler.type';
export type GoogleMessagingNotificationRequest = {
body: GooglePubSubPushMessage;
authorizationHeader: string | undefined;
};
@Injectable()
export class GoogleMessagingNotificationHandler implements WebhookNotificationHandler<GoogleMessagingNotificationRequest> {
constructor(
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
private readonly webhookSyncTriggerService: WebhookSyncTriggerService,
) {}
async handle(request: GoogleMessagingNotificationRequest): Promise<void> {
await this.verify(request.authorizationHeader);
const decodedData = this.decodeMessageData(request.body);
if (!isDefined(decodedData)) {
return;
}
const connectedAccounts = await this.connectedAccountRepository.find({
where: {
handle: decodedData.emailAddress,
provider: ConnectedAccountProvider.GOOGLE,
},
});
const connectedAccountIds = connectedAccounts.map(
(connectedAccount) => connectedAccount.id,
);
if (connectedAccountIds.length === 0) {
return;
}
const messageChannels = await this.messageChannelRepository.find({
where: {
connectedAccountId: In(connectedAccountIds),
webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE,
},
});
for (const messageChannel of messageChannels) {
await this.webhookSyncTriggerService.triggerMessagingSync(
messageChannel.id,
messageChannel.workspaceId,
);
}
}
private async verify(authorizationHeader: string | undefined): Promise<void> {
const expectedEmail = this.twentyConfigService.get(
'MESSAGING_GMAIL_PUBSUB_VERIFICATION_EMAIL',
);
if (!isNonEmptyString(expectedEmail)) {
throw new ConnectedAccountSyncWebhookException(
'MESSAGING_GMAIL_PUBSUB_VERIFICATION_EMAIL is not configured',
ConnectedAccountSyncWebhookExceptionCode.INVALID_SIGNATURE,
);
}
const idToken = authorizationHeader?.replace(/^Bearer\s+/i, '');
if (!isNonEmptyString(idToken)) {
throw new ConnectedAccountSyncWebhookException(
'Missing Pub/Sub OIDC token',
ConnectedAccountSyncWebhookExceptionCode.INVALID_SIGNATURE,
);
}
const expectedAudience = `${this.twentyConfigService.get('SERVER_URL')}/webhooks/google/messaging`;
try {
const ticket = await new OAuth2Client().verifyIdToken({
idToken,
audience: expectedAudience,
});
const payload = ticket.getPayload();
if (
!isDefined(payload) ||
payload.email !== expectedEmail ||
payload.email_verified !== true
) {
throw new ConnectedAccountSyncWebhookException(
'Pub/Sub OIDC token failed verification',
ConnectedAccountSyncWebhookExceptionCode.INVALID_SIGNATURE,
);
}
} catch (error) {
if (error instanceof ConnectedAccountSyncWebhookException) {
throw error;
}
throw new ConnectedAccountSyncWebhookException(
'Pub/Sub OIDC token verification failed',
ConnectedAccountSyncWebhookExceptionCode.INVALID_SIGNATURE,
);
}
}
private decodeMessageData(
body: GooglePubSubPushMessage,
): GmailPushDecodedData | undefined {
const encodedData = body.message?.data;
if (!isNonEmptyString(encodedData)) {
return;
}
const decoded = JSON.parse(
Buffer.from(encodedData, 'base64').toString('utf-8'),
) as GmailPushDecodedData;
if (!isNonEmptyString(decoded.emailAddress)) {
return;
}
return decoded;
}
}
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WebhookSyncTriggerService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-sync-trigger.service';
import { WebhookSubscriptionModule } from 'src/modules/connected-account/webhook-subscription-manager/webhook-subscription.module';
import { GoogleCalendarNotificationHandler } from 'src/modules/connected-account-sync-webhooks/drivers/google/google-calendar-notification.handler';
import { GoogleMessagingNotificationHandler } from 'src/modules/connected-account-sync-webhooks/drivers/google/google-messaging-notification.handler';
@Module({
imports: [
TwentyConfigModule,
WebhookSubscriptionModule,
TypeOrmModule.forFeature([
ConnectedAccountEntity,
MessageChannelEntity,
CalendarChannelEntity,
]),
],
providers: [
GoogleMessagingNotificationHandler,
GoogleCalendarNotificationHandler,
WebhookSyncTriggerService,
],
exports: [
GoogleMessagingNotificationHandler,
GoogleCalendarNotificationHandler,
],
})
export class GoogleWebhookDriverModule {}
@@ -0,0 +1,101 @@
import { timingSafeEqual } from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { CalendarWebhookSubscriptionService } from 'src/modules/connected-account/webhook-subscription-manager/services/calendar-webhook-subscription.service';
import { WebhookSyncTriggerService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-sync-trigger.service';
import { type MicrosoftGraphNotification } from 'src/modules/connected-account-sync-webhooks/types/microsoft-graph-notification.type';
import { type WebhookNotificationHandler } from 'src/modules/connected-account-sync-webhooks/types/webhook-notification-handler.type';
@Injectable()
export class MicrosoftCalendarNotificationHandler implements WebhookNotificationHandler<
MicrosoftGraphNotification[]
> {
private readonly logger = new Logger(
MicrosoftCalendarNotificationHandler.name,
);
constructor(
@InjectRepository(CalendarChannelEntity)
private readonly calendarChannelRepository: Repository<CalendarChannelEntity>,
private readonly calendarWebhookSubscriptionService: CalendarWebhookSubscriptionService,
private readonly webhookSyncTriggerService: WebhookSyncTriggerService,
) {}
async handle(notifications: MicrosoftGraphNotification[]): Promise<void> {
const subscriptionIds = notifications
.map((notification) => notification.subscriptionId)
.filter(isNonEmptyString);
if (subscriptionIds.length === 0) {
return;
}
const calendarChannels = await this.calendarChannelRepository.find({
where: { webhookSubscriptionExternalId: In(subscriptionIds) },
});
const calendarChannelByExternalId = new Map(
calendarChannels.map((calendarChannel) => [
calendarChannel.webhookSubscriptionExternalId,
calendarChannel,
]),
);
for (const notification of notifications) {
const calendarChannel = calendarChannelByExternalId.get(
notification.subscriptionId,
);
if (!isDefined(calendarChannel)) {
this.logger.warn(
`No calendar subscription found for ${notification.subscriptionId}`,
);
continue;
}
const clientState = notification.clientState;
const expectedClientState =
calendarChannel.webhookSubscriptionClientState;
const clientStateBuffer = isNonEmptyString(clientState)
? Buffer.from(clientState)
: null;
const expectedClientStateBuffer = isNonEmptyString(expectedClientState)
? Buffer.from(expectedClientState)
: null;
if (
!isDefined(clientStateBuffer) ||
!isDefined(expectedClientStateBuffer) ||
clientStateBuffer.length !== expectedClientStateBuffer.length ||
!timingSafeEqual(clientStateBuffer, expectedClientStateBuffer)
) {
this.logger.warn(
`Client state mismatch for subscription ${notification.subscriptionId}`,
);
continue;
}
if (
isNonEmptyString(notification.lifecycleEvent) &&
notification.lifecycleEvent !== 'missed'
) {
await this.calendarWebhookSubscriptionService.renewSubscription(
calendarChannel,
);
continue;
}
await this.webhookSyncTriggerService.triggerCalendarSync(
calendarChannel.id,
calendarChannel.workspaceId,
);
}
}
}
@@ -0,0 +1,100 @@
import { timingSafeEqual } from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessagingWebhookSubscriptionService } from 'src/modules/connected-account/webhook-subscription-manager/services/messaging-webhook-subscription.service';
import { WebhookSyncTriggerService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-sync-trigger.service';
import { type MicrosoftGraphNotification } from 'src/modules/connected-account-sync-webhooks/types/microsoft-graph-notification.type';
import { type WebhookNotificationHandler } from 'src/modules/connected-account-sync-webhooks/types/webhook-notification-handler.type';
@Injectable()
export class MicrosoftMessagingNotificationHandler implements WebhookNotificationHandler<
MicrosoftGraphNotification[]
> {
private readonly logger = new Logger(
MicrosoftMessagingNotificationHandler.name,
);
constructor(
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
private readonly messagingWebhookSubscriptionService: MessagingWebhookSubscriptionService,
private readonly webhookSyncTriggerService: WebhookSyncTriggerService,
) {}
async handle(notifications: MicrosoftGraphNotification[]): Promise<void> {
const subscriptionIds = notifications
.map((notification) => notification.subscriptionId)
.filter(isNonEmptyString);
if (subscriptionIds.length === 0) {
return;
}
const messageChannels = await this.messageChannelRepository.find({
where: { webhookSubscriptionExternalId: In(subscriptionIds) },
});
const messageChannelByExternalId = new Map(
messageChannels.map((messageChannel) => [
messageChannel.webhookSubscriptionExternalId,
messageChannel,
]),
);
for (const notification of notifications) {
const messageChannel = messageChannelByExternalId.get(
notification.subscriptionId,
);
if (!isDefined(messageChannel)) {
this.logger.warn(
`No messaging subscription found for ${notification.subscriptionId}`,
);
continue;
}
const clientState = notification.clientState;
const expectedClientState = messageChannel.webhookSubscriptionClientState;
const clientStateBuffer = isNonEmptyString(clientState)
? Buffer.from(clientState)
: null;
const expectedClientStateBuffer = isNonEmptyString(expectedClientState)
? Buffer.from(expectedClientState)
: null;
if (
!isDefined(clientStateBuffer) ||
!isDefined(expectedClientStateBuffer) ||
clientStateBuffer.length !== expectedClientStateBuffer.length ||
!timingSafeEqual(clientStateBuffer, expectedClientStateBuffer)
) {
this.logger.warn(
`Client state mismatch for subscription ${notification.subscriptionId}`,
);
continue;
}
if (
isNonEmptyString(notification.lifecycleEvent) &&
notification.lifecycleEvent !== 'missed'
) {
await this.messagingWebhookSubscriptionService.renewSubscription(
messageChannel,
);
continue;
}
await this.webhookSyncTriggerService.triggerMessagingSync(
messageChannel.id,
messageChannel.workspaceId,
);
}
}
}
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WebhookSyncTriggerService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-sync-trigger.service';
import { WebhookSubscriptionModule } from 'src/modules/connected-account/webhook-subscription-manager/webhook-subscription.module';
import { MicrosoftCalendarNotificationHandler } from 'src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-calendar-notification.handler';
import { MicrosoftMessagingNotificationHandler } from 'src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-messaging-notification.handler';
@Module({
imports: [
TwentyConfigModule,
WebhookSubscriptionModule,
TypeOrmModule.forFeature([MessageChannelEntity, CalendarChannelEntity]),
],
providers: [
MicrosoftMessagingNotificationHandler,
MicrosoftCalendarNotificationHandler,
WebhookSyncTriggerService,
],
exports: [
MicrosoftMessagingNotificationHandler,
MicrosoftCalendarNotificationHandler,
],
})
export class MicrosoftWebhookDriverModule {}
@@ -0,0 +1,29 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { ConnectedAccountSyncWebhookException } from 'src/modules/connected-account-sync-webhooks/connected-account-sync-webhook.exception';
import { getConnectedAccountSyncWebhookExceptionStatusCode } from 'src/modules/connected-account-sync-webhooks/utils/get-connected-account-sync-webhook-exception-status-code.util';
@Catch(ConnectedAccountSyncWebhookException)
export class ConnectedAccountSyncWebhookApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: ConnectedAccountSyncWebhookException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
return this.httpExceptionHandlerService.handleError(
exception,
response,
getConnectedAccountSyncWebhookExceptionStatusCode(exception),
);
}
}
@@ -0,0 +1,5 @@
export type GoogleCalendarChannelNotification = {
channelId: string | undefined;
resourceState: string | undefined;
channelToken: string | undefined;
};
@@ -0,0 +1,13 @@
export type GooglePubSubPushMessage = {
message?: {
data?: string;
messageId?: string;
publishTime?: string;
};
subscription?: string;
};
export type GmailPushDecodedData = {
emailAddress: string;
historyId: string | number;
};
@@ -0,0 +1,12 @@
export type MicrosoftGraphNotification = {
subscriptionId: string;
clientState?: string;
changeType?: string;
resource?: string;
lifecycleEvent?: string;
resourceData?: { id?: string } | null;
};
export type MicrosoftGraphNotificationPayload = {
value?: MicrosoftGraphNotification[];
};
@@ -0,0 +1,3 @@
export type WebhookNotificationHandler<TRequest> = {
handle(request: TRequest): Promise<void>;
};
@@ -0,0 +1,21 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ConnectedAccountSyncWebhookException,
ConnectedAccountSyncWebhookExceptionCode,
} from 'src/modules/connected-account-sync-webhooks/connected-account-sync-webhook.exception';
export const getConnectedAccountSyncWebhookExceptionStatusCode = (
exception: ConnectedAccountSyncWebhookException,
): 400 | 403 => {
switch (exception.code) {
case ConnectedAccountSyncWebhookExceptionCode.MISSING_REQUEST_BODY:
case ConnectedAccountSyncWebhookExceptionCode.INVALID_PAYLOAD:
return 400;
case ConnectedAccountSyncWebhookExceptionCode.INVALID_SIGNATURE:
return 403;
default: {
return assertUnreachable(exception.code);
}
}
};