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:
+39
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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 {}
|
||||
+76
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+154
@@ -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;
|
||||
}
|
||||
}
|
||||
+33
@@ -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 {}
|
||||
+101
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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 {}
|
||||
+29
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type GoogleCalendarChannelNotification = {
|
||||
channelId: string | undefined;
|
||||
resourceState: string | undefined;
|
||||
channelToken: string | undefined;
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export type GooglePubSubPushMessage = {
|
||||
message?: {
|
||||
data?: string;
|
||||
messageId?: string;
|
||||
publishTime?: string;
|
||||
};
|
||||
subscription?: string;
|
||||
};
|
||||
|
||||
export type GmailPushDecodedData = {
|
||||
emailAddress: string;
|
||||
historyId: string | number;
|
||||
};
|
||||
+12
@@ -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[];
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export type WebhookNotificationHandler<TRequest> = {
|
||||
handle(request: TRequest): Promise<void>;
|
||||
};
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user