@memberjunction/communication-gmail 6.2.0-edge.4 → 6.2.0-edge.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/GmailProvider.d.ts +70 -3
- package/dist/GmailProvider.d.ts.map +1 -1
- package/dist/GmailProvider.js +588 -13
- package/dist/GmailProvider.js.map +1 -1
- package/dist/__tests__/GmailCalendar.test.d.ts +2 -0
- package/dist/__tests__/GmailCalendar.test.d.ts.map +1 -0
- package/dist/__tests__/GmailCalendar.test.js +292 -0
- package/dist/__tests__/GmailCalendar.test.js.map +1 -0
- package/dist/__tests__/GmailCalendarAndPreflight.test.d.ts +2 -0
- package/dist/__tests__/GmailCalendarAndPreflight.test.d.ts.map +1 -0
- package/dist/__tests__/GmailCalendarAndPreflight.test.js +251 -0
- package/dist/__tests__/GmailCalendarAndPreflight.test.js.map +1 -0
- package/dist/calendar.d.ts +25 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +119 -0
- package/dist/calendar.js.map +1 -0
- package/package.json +4 -4
package/dist/GmailProvider.js
CHANGED
|
@@ -5,17 +5,20 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
import { UUIDsEqual } from '@memberjunction/global';
|
|
8
|
-
import { BaseCommunicationProvider, CombineFilterClauses, ParseEmailAddressList, resolveCredentialValue, validateRequiredCredentials } from "@memberjunction/communication-types";
|
|
8
|
+
import { BaseCommunicationProvider, CombineFilterClauses, ParseEmailAddressList, resolveCredentialValue, validateRequiredCredentials, CalendarTargetError, CalendarUpdateError } from "@memberjunction/communication-types";
|
|
9
9
|
import { RegisterClass, MJLruCache } from "@memberjunction/global";
|
|
10
10
|
import { LogError, LogStatus } from "@memberjunction/core";
|
|
11
11
|
import * as Config from "./config.js";
|
|
12
12
|
import googleApis from 'googleapis';
|
|
13
|
+
import { BuildGoogleEventBody, ToCalendarChange, ToCalendarEvent } from "./calendar.js";
|
|
13
14
|
/**
|
|
14
15
|
* Gmail's mailbox watch (a `users.watch` registration) expires after roughly 7 days,
|
|
15
16
|
* so the consumer MUST re-watch before then. Expressed in minutes (7 × 24 × 60) to match
|
|
16
17
|
* {@link SubscriptionCapabilities.MaxLifetimeMinutes}.
|
|
17
18
|
*/
|
|
18
19
|
const GMAIL_MAX_SUBSCRIPTION_MINUTES = 10080;
|
|
20
|
+
/** A guard against a misbehaving sync stream, not a real limit. */
|
|
21
|
+
const GOOGLE_MAX_SYNC_PAGES = 1000;
|
|
19
22
|
/**
|
|
20
23
|
* Implementation of the Gmail provider for sending and receiving messages
|
|
21
24
|
*/
|
|
@@ -76,7 +79,7 @@ let GmailProvider = class GmailProvider extends BaseCommunicationProvider {
|
|
|
76
79
|
};
|
|
77
80
|
}
|
|
78
81
|
/**
|
|
79
|
-
* Creates a Gmail client with the given credentials
|
|
82
|
+
* Creates a Gmail client with the given credentials, keeping its OAuth2 client for the calendar
|
|
80
83
|
*/
|
|
81
84
|
createGmailClient(creds) {
|
|
82
85
|
// Create OAuth2 client
|
|
@@ -86,10 +89,11 @@ let GmailProvider = class GmailProvider extends BaseCommunicationProvider {
|
|
|
86
89
|
refresh_token: creds.refreshToken
|
|
87
90
|
});
|
|
88
91
|
// Create Gmail API client
|
|
89
|
-
|
|
92
|
+
const client = googleApis.google.gmail({
|
|
90
93
|
version: 'v1',
|
|
91
94
|
auth: oauth2Client
|
|
92
95
|
});
|
|
96
|
+
return { client, userEmail: null, auth: oauth2Client };
|
|
93
97
|
}
|
|
94
98
|
/**
|
|
95
99
|
* Gets a Gmail client for the given credentials, using caching for efficiency
|
|
@@ -101,10 +105,7 @@ let GmailProvider = class GmailProvider extends BaseCommunicationProvider {
|
|
|
101
105
|
creds.refreshToken === Config.GMAIL_REFRESH_TOKEN;
|
|
102
106
|
if (isEnvCredentials) {
|
|
103
107
|
if (!this.envGmailClient) {
|
|
104
|
-
this.envGmailClient =
|
|
105
|
-
client: this.createGmailClient(creds),
|
|
106
|
-
userEmail: null
|
|
107
|
-
};
|
|
108
|
+
this.envGmailClient = this.createGmailClient(creds);
|
|
108
109
|
}
|
|
109
110
|
return this.envGmailClient;
|
|
110
111
|
}
|
|
@@ -112,10 +113,7 @@ let GmailProvider = class GmailProvider extends BaseCommunicationProvider {
|
|
|
112
113
|
const cacheKey = `${creds.clientId}:${creds.refreshToken.substring(0, 10)}`;
|
|
113
114
|
let cached = this.clientCache.Get(cacheKey);
|
|
114
115
|
if (!cached) {
|
|
115
|
-
cached =
|
|
116
|
-
client: this.createGmailClient(creds),
|
|
117
|
-
userEmail: null
|
|
118
|
-
};
|
|
116
|
+
cached = this.createGmailClient(creds);
|
|
119
117
|
this.clientCache.Set(cacheKey, cached);
|
|
120
118
|
}
|
|
121
119
|
return cached;
|
|
@@ -639,7 +637,7 @@ let GmailProvider = class GmailProvider extends BaseCommunicationProvider {
|
|
|
639
637
|
// ========================================================================
|
|
640
638
|
/**
|
|
641
639
|
* Returns the list of operations supported by the Gmail provider.
|
|
642
|
-
* Gmail supports all
|
|
640
|
+
* Gmail supports all mailbox and Google Calendar v3 operations.
|
|
643
641
|
*/
|
|
644
642
|
getSupportedOperations() {
|
|
645
643
|
return [
|
|
@@ -660,9 +658,586 @@ let GmailProvider = class GmailProvider extends BaseCommunicationProvider {
|
|
|
660
658
|
'CreateSubscription',
|
|
661
659
|
'RenewSubscription',
|
|
662
660
|
'DeleteSubscription',
|
|
663
|
-
'ParseNotification'
|
|
661
|
+
'ParseNotification',
|
|
662
|
+
'CreateEvent',
|
|
663
|
+
'UpdateEvent',
|
|
664
|
+
'CancelEvent',
|
|
665
|
+
'GetEvent',
|
|
666
|
+
'SyncEvents',
|
|
667
|
+
'GetEvents',
|
|
668
|
+
'GetFreeBusy',
|
|
669
|
+
'CreateCalendarEvent',
|
|
670
|
+
'UpdateCalendarEvent',
|
|
671
|
+
'RespondToCalendarEvent',
|
|
672
|
+
'CancelCalendarEvent',
|
|
673
|
+
'VerifyMailboxAccess'
|
|
664
674
|
];
|
|
665
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Creates an authenticated Google Calendar v3 client using the same OAuth2 credentials.
|
|
678
|
+
*/
|
|
679
|
+
createCalendarClient(creds) {
|
|
680
|
+
const oauth2Client = new googleApis.google.auth.OAuth2(creds.clientId, creds.clientSecret, creds.redirectUri);
|
|
681
|
+
oauth2Client.setCredentials({
|
|
682
|
+
refresh_token: creds.refreshToken
|
|
683
|
+
});
|
|
684
|
+
return googleApis.google.calendar({
|
|
685
|
+
version: 'v3',
|
|
686
|
+
auth: oauth2Client
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Normalizes a Google Calendar v3 event into MJ's GetEventsEvent format.
|
|
691
|
+
*/
|
|
692
|
+
toGetEventsEvent(raw) {
|
|
693
|
+
const startRaw = raw.start?.dateTime ?? raw.start?.date ?? null;
|
|
694
|
+
const endRaw = raw.end?.dateTime ?? raw.end?.date ?? null;
|
|
695
|
+
const startDate = startRaw ? new Date(startRaw) : null;
|
|
696
|
+
const endDate = endRaw ? new Date(endRaw) : null;
|
|
697
|
+
const organizer = raw.organizer?.email?.trim().toLowerCase() || undefined;
|
|
698
|
+
const attendees = (raw.attendees ?? [])
|
|
699
|
+
.map((a) => a.email?.trim().toLowerCase())
|
|
700
|
+
.filter((a) => !!a && a !== organizer);
|
|
701
|
+
return {
|
|
702
|
+
ExternalSystemRecordID: raw.id ?? '',
|
|
703
|
+
SeriesID: raw.recurringEventId ?? null,
|
|
704
|
+
Subject: raw.summary ?? '',
|
|
705
|
+
Body: raw.description ?? '',
|
|
706
|
+
StartTime: startDate && !Number.isNaN(startDate.getTime()) ? startDate : null,
|
|
707
|
+
EndTime: endDate && !Number.isNaN(endDate.getTime()) ? endDate : null,
|
|
708
|
+
Location: raw.location ?? null,
|
|
709
|
+
Organizer: organizer,
|
|
710
|
+
Attendees: [...new Set(attendees)],
|
|
711
|
+
IsCancelled: raw.status === 'cancelled'
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Lists calendar events from Google Calendar v3 (`calendar.events.list`).
|
|
716
|
+
*/
|
|
717
|
+
async GetEvents(params, credentials) {
|
|
718
|
+
try {
|
|
719
|
+
const creds = this.resolveCredentials(credentials);
|
|
720
|
+
const calendar = this.createCalendarClient(creds);
|
|
721
|
+
const calendarId = params.Identifier || params.ContextData?.Email || 'primary';
|
|
722
|
+
const expand = Boolean(params.StartDateTime && params.EndDateTime);
|
|
723
|
+
const response = await calendar.events.list({
|
|
724
|
+
calendarId,
|
|
725
|
+
timeMin: params.StartDateTime?.toISOString(),
|
|
726
|
+
timeMax: params.EndDateTime?.toISOString(),
|
|
727
|
+
singleEvents: expand,
|
|
728
|
+
orderBy: expand ? 'startTime' : 'updated',
|
|
729
|
+
showDeleted: Boolean(params.IncludeCancelled),
|
|
730
|
+
maxResults: params.NumEvents
|
|
731
|
+
});
|
|
732
|
+
const items = response?.data?.items ?? [];
|
|
733
|
+
return {
|
|
734
|
+
Success: true,
|
|
735
|
+
Events: items.map((e) => this.toGetEventsEvent(e)),
|
|
736
|
+
RecurrenceExpanded: expand,
|
|
737
|
+
SourceData: items
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
742
|
+
LogError('Error getting calendar events via Google Calendar', undefined, error);
|
|
743
|
+
return {
|
|
744
|
+
Success: false,
|
|
745
|
+
Events: [],
|
|
746
|
+
ErrorMessage: `Google Calendar read failed: ${errorMessage}`
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Queries free/busy schedule intervals across one or more Google Calendars
|
|
752
|
+
* via `calendar.freebusy.query`.
|
|
753
|
+
*/
|
|
754
|
+
async GetFreeBusy(params, credentials) {
|
|
755
|
+
if (!params.Schedules || params.Schedules.length === 0) {
|
|
756
|
+
return {
|
|
757
|
+
Success: false,
|
|
758
|
+
Schedules: [],
|
|
759
|
+
ErrorMessage: 'GetFreeBusy requires at least one email address in Schedules.'
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
const creds = this.resolveCredentials(credentials);
|
|
764
|
+
const calendar = this.createCalendarClient(creds);
|
|
765
|
+
const response = await calendar.freebusy.query({
|
|
766
|
+
requestBody: {
|
|
767
|
+
timeMin: params.StartDateTime.toISOString(),
|
|
768
|
+
timeMax: params.EndDateTime.toISOString(),
|
|
769
|
+
items: params.Schedules.map((id) => ({ id }))
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
const calendarsMap = response?.data?.calendars ?? {};
|
|
773
|
+
const schedules = params.Schedules.map((scheduleId) => {
|
|
774
|
+
const calEntry = calendarsMap[scheduleId];
|
|
775
|
+
const busyList = calEntry?.busy ?? [];
|
|
776
|
+
const errors = calEntry?.errors ?? [];
|
|
777
|
+
const slots = [];
|
|
778
|
+
for (const interval of busyList) {
|
|
779
|
+
if (!interval.start || !interval.end)
|
|
780
|
+
continue;
|
|
781
|
+
slots.push({
|
|
782
|
+
StartTime: new Date(interval.start),
|
|
783
|
+
EndTime: new Date(interval.end),
|
|
784
|
+
Status: 'Busy'
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
ScheduleID: scheduleId,
|
|
789
|
+
Slots: slots,
|
|
790
|
+
Error: errors.length > 0 ? errors.map((e) => e.reason ?? 'error').join(', ') : undefined
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
return {
|
|
794
|
+
Success: true,
|
|
795
|
+
Schedules: schedules,
|
|
796
|
+
SourceData: [response?.data]
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
801
|
+
LogError('Error getting free/busy via Google Calendar', undefined, error);
|
|
802
|
+
return {
|
|
803
|
+
Success: false,
|
|
804
|
+
Schedules: [],
|
|
805
|
+
ErrorMessage: `Error getting free/busy schedule: ${errorMessage}`
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Creates a Google Calendar event via `calendar.events.insert`.
|
|
811
|
+
*/
|
|
812
|
+
async CreateCalendarEvent(params, credentials) {
|
|
813
|
+
try {
|
|
814
|
+
const creds = this.resolveCredentials(credentials);
|
|
815
|
+
const calendarId = params.Identifier || params.ContextData?.Email || 'primary';
|
|
816
|
+
const requestBody = {
|
|
817
|
+
summary: params.Subject,
|
|
818
|
+
description: params.Body,
|
|
819
|
+
location: params.Location,
|
|
820
|
+
start: {
|
|
821
|
+
dateTime: params.StartTime.toISOString(),
|
|
822
|
+
timeZone: params.TimeZone ?? 'UTC'
|
|
823
|
+
},
|
|
824
|
+
end: {
|
|
825
|
+
dateTime: params.EndTime.toISOString(),
|
|
826
|
+
timeZone: params.TimeZone ?? 'UTC'
|
|
827
|
+
},
|
|
828
|
+
attendees: params.Attendees?.map((a) => ({
|
|
829
|
+
email: a.Email,
|
|
830
|
+
displayName: a.Name,
|
|
831
|
+
optional: a.Required === false
|
|
832
|
+
}))
|
|
833
|
+
};
|
|
834
|
+
if (params.IsOnlineMeeting) {
|
|
835
|
+
requestBody.conferenceData = {
|
|
836
|
+
createRequest: {
|
|
837
|
+
requestId: `mj-${Date.now()}`
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
if (params.DryRun) {
|
|
842
|
+
LogStatus(`[DryRun] Google Calendar: CreateCalendarEvent '${params.Subject}' for ${calendarId} — external call skipped`);
|
|
843
|
+
return {
|
|
844
|
+
Success: true,
|
|
845
|
+
DryRun: true,
|
|
846
|
+
Event: {
|
|
847
|
+
ExternalSystemRecordID: 'DRY_RUN_EVENT_ID',
|
|
848
|
+
SeriesID: null,
|
|
849
|
+
Subject: params.Subject,
|
|
850
|
+
Body: params.Body ?? '',
|
|
851
|
+
StartTime: params.StartTime,
|
|
852
|
+
EndTime: params.EndTime,
|
|
853
|
+
Location: params.Location ?? null,
|
|
854
|
+
Organizer: calendarId.toLowerCase(),
|
|
855
|
+
Attendees: (params.Attendees ?? []).map((a) => a.Email.toLowerCase()),
|
|
856
|
+
IsCancelled: false
|
|
857
|
+
},
|
|
858
|
+
SourceData: requestBody
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
const calendar = this.createCalendarClient(creds);
|
|
862
|
+
const response = await calendar.events.insert({
|
|
863
|
+
calendarId,
|
|
864
|
+
conferenceDataVersion: params.IsOnlineMeeting ? 1 : 0,
|
|
865
|
+
requestBody
|
|
866
|
+
});
|
|
867
|
+
const created = response?.data;
|
|
868
|
+
return {
|
|
869
|
+
Success: true,
|
|
870
|
+
Event: created ? this.toGetEventsEvent(created) : undefined,
|
|
871
|
+
OnlineMeetingUrl: created?.hangoutLink ?? created?.conferenceData?.entryPoints?.[0]?.uri ?? null,
|
|
872
|
+
SourceData: created
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
877
|
+
LogError('Error creating calendar event via Google Calendar', undefined, error);
|
|
878
|
+
return {
|
|
879
|
+
Success: false,
|
|
880
|
+
ErrorMessage: `Error creating calendar event: ${errorMessage}`
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Updates an existing Google Calendar event via `calendar.events.patch`.
|
|
886
|
+
*/
|
|
887
|
+
async UpdateCalendarEvent(params, credentials) {
|
|
888
|
+
try {
|
|
889
|
+
const creds = this.resolveCredentials(credentials);
|
|
890
|
+
const calendarId = params.Identifier || params.ContextData?.Email || 'primary';
|
|
891
|
+
const requestBody = {};
|
|
892
|
+
if (params.Subject !== undefined)
|
|
893
|
+
requestBody.summary = params.Subject;
|
|
894
|
+
if (params.Body !== undefined)
|
|
895
|
+
requestBody.description = params.Body;
|
|
896
|
+
if (params.Location !== undefined)
|
|
897
|
+
requestBody.location = params.Location;
|
|
898
|
+
if (params.StartTime) {
|
|
899
|
+
requestBody.start = {
|
|
900
|
+
dateTime: params.StartTime.toISOString(),
|
|
901
|
+
timeZone: params.TimeZone ?? 'UTC'
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
if (params.EndTime) {
|
|
905
|
+
requestBody.end = {
|
|
906
|
+
dateTime: params.EndTime.toISOString(),
|
|
907
|
+
timeZone: params.TimeZone ?? 'UTC'
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
if (params.Attendees) {
|
|
911
|
+
requestBody.attendees = params.Attendees.map((a) => ({
|
|
912
|
+
email: a.Email,
|
|
913
|
+
displayName: a.Name,
|
|
914
|
+
optional: a.Required === false
|
|
915
|
+
}));
|
|
916
|
+
}
|
|
917
|
+
if (params.DryRun) {
|
|
918
|
+
LogStatus(`[DryRun] Google Calendar: UpdateCalendarEvent '${params.EventID}' for ${calendarId} — external call skipped`);
|
|
919
|
+
return {
|
|
920
|
+
Success: true,
|
|
921
|
+
DryRun: true,
|
|
922
|
+
SourceData: requestBody
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
const calendar = this.createCalendarClient(creds);
|
|
926
|
+
const response = await calendar.events.patch({
|
|
927
|
+
calendarId,
|
|
928
|
+
eventId: params.EventID,
|
|
929
|
+
requestBody
|
|
930
|
+
});
|
|
931
|
+
return {
|
|
932
|
+
Success: true,
|
|
933
|
+
Event: response?.data ? this.toGetEventsEvent(response.data) : undefined,
|
|
934
|
+
SourceData: response?.data
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
939
|
+
LogError('Error updating calendar event via Google Calendar', undefined, error);
|
|
940
|
+
return {
|
|
941
|
+
Success: false,
|
|
942
|
+
ErrorMessage: `Error updating calendar event: ${errorMessage}`
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Responds to a Google Calendar event invitation by updating the attendee's responseStatus.
|
|
948
|
+
*/
|
|
949
|
+
async RespondToCalendarEvent(params, credentials) {
|
|
950
|
+
try {
|
|
951
|
+
const creds = this.resolveCredentials(credentials);
|
|
952
|
+
const calendarId = params.Identifier || params.ContextData?.Email || 'primary';
|
|
953
|
+
if (params.DryRun) {
|
|
954
|
+
LogStatus(`[DryRun] Google Calendar: RespondToCalendarEvent (${params.Response}) '${params.EventID}' for ${calendarId} — external call skipped`);
|
|
955
|
+
return { Success: true, DryRun: true };
|
|
956
|
+
}
|
|
957
|
+
const responseStatus = params.Response === 'Accept'
|
|
958
|
+
? 'accepted'
|
|
959
|
+
: params.Response === 'TentativelyAccept'
|
|
960
|
+
? 'tentative'
|
|
961
|
+
: 'declined';
|
|
962
|
+
const calendar = this.createCalendarClient(creds);
|
|
963
|
+
const existing = await calendar.events.get({
|
|
964
|
+
calendarId,
|
|
965
|
+
eventId: params.EventID
|
|
966
|
+
});
|
|
967
|
+
const attendees = [...(existing?.data?.attendees ?? [])];
|
|
968
|
+
const targetEmail = calendarId.toLowerCase();
|
|
969
|
+
let matched = false;
|
|
970
|
+
for (const att of attendees) {
|
|
971
|
+
if (att.self || (att.email && att.email.toLowerCase() === targetEmail)) {
|
|
972
|
+
att.responseStatus = responseStatus;
|
|
973
|
+
if (params.Comment) {
|
|
974
|
+
att.comment = params.Comment;
|
|
975
|
+
}
|
|
976
|
+
matched = true;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (!matched && targetEmail.includes('@')) {
|
|
980
|
+
attendees.push({
|
|
981
|
+
email: calendarId,
|
|
982
|
+
responseStatus,
|
|
983
|
+
comment: params.Comment
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
await calendar.events.patch({
|
|
987
|
+
calendarId,
|
|
988
|
+
eventId: params.EventID,
|
|
989
|
+
sendUpdates: params.SendResponse === false ? 'none' : 'all',
|
|
990
|
+
requestBody: { attendees }
|
|
991
|
+
});
|
|
992
|
+
return { Success: true };
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
996
|
+
LogError('Error responding to calendar event via Google Calendar', undefined, error);
|
|
997
|
+
return {
|
|
998
|
+
Success: false,
|
|
999
|
+
ErrorMessage: `Error responding to calendar event: ${errorMessage}`
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Cancels (deletes) a Google Calendar event via `calendar.events.delete`.
|
|
1005
|
+
*/
|
|
1006
|
+
async CancelCalendarEvent(params, credentials) {
|
|
1007
|
+
try {
|
|
1008
|
+
const creds = this.resolveCredentials(credentials);
|
|
1009
|
+
const calendarId = params.Identifier || params.ContextData?.Email || 'primary';
|
|
1010
|
+
if (params.DryRun) {
|
|
1011
|
+
LogStatus(`[DryRun] Google Calendar: CancelCalendarEvent '${params.EventID}' for ${calendarId} — external call skipped`);
|
|
1012
|
+
return { Success: true, DryRun: true };
|
|
1013
|
+
}
|
|
1014
|
+
const calendar = this.createCalendarClient(creds);
|
|
1015
|
+
await calendar.events.delete({
|
|
1016
|
+
calendarId,
|
|
1017
|
+
eventId: params.EventID,
|
|
1018
|
+
sendUpdates: 'all'
|
|
1019
|
+
});
|
|
1020
|
+
return { Success: true };
|
|
1021
|
+
}
|
|
1022
|
+
catch (error) {
|
|
1023
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1024
|
+
LogError('Error cancelling calendar event via Google Calendar', undefined, error);
|
|
1025
|
+
return {
|
|
1026
|
+
Success: false,
|
|
1027
|
+
ErrorMessage: `Error cancelling calendar event: ${errorMessage}`
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Verifies whether the active Gmail / Google Workspace credential has access to
|
|
1033
|
+
* the specified mailbox and/or calendar.
|
|
1034
|
+
*/
|
|
1035
|
+
async VerifyMailboxAccess(params, credentials) {
|
|
1036
|
+
const identifier = params.Identifier?.trim();
|
|
1037
|
+
if (!identifier) {
|
|
1038
|
+
return {
|
|
1039
|
+
Success: false,
|
|
1040
|
+
Identifier: '',
|
|
1041
|
+
CanReadMail: false,
|
|
1042
|
+
CanReadCalendar: false,
|
|
1043
|
+
ErrorMessage: 'VerifyMailboxAccess requires a non-empty Identifier (mailbox email).'
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
try {
|
|
1047
|
+
const creds = this.resolveCredentials(credentials);
|
|
1048
|
+
const cached = this.getGmailClient(creds);
|
|
1049
|
+
const checkMail = params.CheckMailRead ?? true;
|
|
1050
|
+
const checkCalendar = params.CheckCalendarRead ?? true;
|
|
1051
|
+
let canReadMail = false;
|
|
1052
|
+
let canReadCalendar = false;
|
|
1053
|
+
let mailErrorMessage;
|
|
1054
|
+
let calendarErrorMessage;
|
|
1055
|
+
if (checkMail) {
|
|
1056
|
+
try {
|
|
1057
|
+
const profile = await cached.client.users.getProfile({ userId: 'me' });
|
|
1058
|
+
const profileEmail = profile?.data?.emailAddress?.trim().toLowerCase();
|
|
1059
|
+
if (profileEmail && profileEmail !== identifier.toLowerCase()) {
|
|
1060
|
+
canReadMail = false;
|
|
1061
|
+
mailErrorMessage = `Authenticated Gmail profile (${profileEmail}) does not match target mailbox (${identifier}).`;
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
canReadMail = true;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
catch (mailEx) {
|
|
1068
|
+
mailErrorMessage = mailEx instanceof Error ? mailEx.message : String(mailEx);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (checkCalendar) {
|
|
1072
|
+
try {
|
|
1073
|
+
const calendar = this.createCalendarClient(creds);
|
|
1074
|
+
await calendar.calendars.get({ calendarId: identifier });
|
|
1075
|
+
canReadCalendar = true;
|
|
1076
|
+
}
|
|
1077
|
+
catch (calEx) {
|
|
1078
|
+
calendarErrorMessage = calEx instanceof Error ? calEx.message : String(calEx);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const overallSuccess = (!checkMail || canReadMail) &&
|
|
1082
|
+
(!checkCalendar || canReadCalendar);
|
|
1083
|
+
return {
|
|
1084
|
+
Success: overallSuccess,
|
|
1085
|
+
Identifier: identifier,
|
|
1086
|
+
CanReadMail: canReadMail,
|
|
1087
|
+
CanReadCalendar: canReadCalendar,
|
|
1088
|
+
MailErrorMessage: mailErrorMessage,
|
|
1089
|
+
CalendarErrorMessage: calendarErrorMessage,
|
|
1090
|
+
ErrorMessage: overallSuccess
|
|
1091
|
+
? undefined
|
|
1092
|
+
: [mailErrorMessage, calendarErrorMessage].filter(Boolean).join('; ')
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
catch (error) {
|
|
1096
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1097
|
+
return {
|
|
1098
|
+
Success: false,
|
|
1099
|
+
Identifier: identifier,
|
|
1100
|
+
CanReadMail: false,
|
|
1101
|
+
CanReadCalendar: false,
|
|
1102
|
+
ErrorMessage: `Error verifying mailbox access for ${identifier}: ${errorMessage}`
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
// ========================================================================
|
|
1107
|
+
// GOOGLE CALENDAR
|
|
1108
|
+
// On this provider rather than a separate one because it is the same Google account and the same
|
|
1109
|
+
// OAuth2 client: the `MJ: Communication Providers` row named 'Gmail' already stands for the
|
|
1110
|
+
// account, and a calendar link points at that row. The calendar client is built from the same
|
|
1111
|
+
// OAuth2 client on first use.
|
|
1112
|
+
//
|
|
1113
|
+
// SCOPES: the refresh token must carry https://www.googleapis.com/auth/calendar.events (or the
|
|
1114
|
+
// broader .../auth/calendar). A token consented for Gmail scopes only gets 403 here, and the account
|
|
1115
|
+
// has to consent again with the calendar scope added.
|
|
1116
|
+
//
|
|
1117
|
+
// WHOSE CALENDAR: the refresh token decides the account, and Target.Identifier names the calendar.
|
|
1118
|
+
// Target.CalendarID defaults to Target.Identifier, not 'primary': a Google account's primary
|
|
1119
|
+
// calendar ID is its address, so for the token's own account the two are the same calendar, and for
|
|
1120
|
+
// anyone else Google answers 404 instead of writing into the token owner's calendar (the deployment
|
|
1121
|
+
// default these operations must never fall back to).
|
|
1122
|
+
// ========================================================================
|
|
1123
|
+
/** Inserts an event and sends invitations to its attendees (`sendUpdates: 'all'`). */
|
|
1124
|
+
async CreateEvent(params, credentials) {
|
|
1125
|
+
return this.calendarCall('CreateEvent', params.Target, credentials, (message) => ({ Success: false, ErrorMessage: message }), async (calendar, calendarId) => {
|
|
1126
|
+
const response = await calendar.events.insert({
|
|
1127
|
+
calendarId,
|
|
1128
|
+
sendUpdates: 'all',
|
|
1129
|
+
...(params.Event.IsOnlineMeeting ? { conferenceDataVersion: 1 } : {}),
|
|
1130
|
+
requestBody: BuildGoogleEventBody(params.Event)
|
|
1131
|
+
});
|
|
1132
|
+
return { Success: true, Event: ToCalendarEvent(response.data) };
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
/** Patches the given fields and sends the update to attendees. */
|
|
1136
|
+
async UpdateEvent(params, credentials) {
|
|
1137
|
+
const changeError = CalendarUpdateError(params.Changes);
|
|
1138
|
+
if (changeError)
|
|
1139
|
+
return { Success: false, ErrorMessage: changeError };
|
|
1140
|
+
return this.calendarCall('UpdateEvent', params.Target, credentials, (message) => ({ Success: false, ErrorMessage: message }), async (calendar, calendarId) => {
|
|
1141
|
+
const response = await calendar.events.patch({
|
|
1142
|
+
calendarId,
|
|
1143
|
+
eventId: params.ExternalEventID,
|
|
1144
|
+
sendUpdates: 'all',
|
|
1145
|
+
...(params.Changes.IsOnlineMeeting ? { conferenceDataVersion: 1 } : {}),
|
|
1146
|
+
requestBody: BuildGoogleEventBody(params.Changes, true)
|
|
1147
|
+
});
|
|
1148
|
+
return { Success: true, Event: ToCalendarEvent(response.data) };
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Deletes the event with `sendUpdates: 'all'`: from the organizer's calendar that cancels it for
|
|
1153
|
+
* everyone; from an attendee's it removes that copy only. Google has no cancellation comment, so
|
|
1154
|
+
* `Comment` is not sent. An event already gone (404 or 410) counts as cancelled.
|
|
1155
|
+
*/
|
|
1156
|
+
async CancelEvent(params, credentials) {
|
|
1157
|
+
const failure = (message, err) => {
|
|
1158
|
+
const status = this.httpStatus(err);
|
|
1159
|
+
return status === 404 || status === 410 ? { Success: true } : { Success: false, ErrorMessage: message };
|
|
1160
|
+
};
|
|
1161
|
+
return this.calendarCall('CancelEvent', params.Target, credentials, failure, async (calendar, calendarId) => {
|
|
1162
|
+
await calendar.events.delete({ calendarId, eventId: params.ExternalEventID, sendUpdates: 'all' });
|
|
1163
|
+
return { Success: true };
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
/** Reads one event with each attendee's response. */
|
|
1167
|
+
async GetEvent(params, credentials) {
|
|
1168
|
+
return this.calendarCall('GetEvent', params.Target, credentials, (message) => ({ Success: false, ErrorMessage: message }), async (calendar, calendarId) => {
|
|
1169
|
+
const response = await calendar.events.get({ calendarId, eventId: params.ExternalEventID });
|
|
1170
|
+
return { Success: true, Event: ToCalendarEvent(response.data) };
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Incremental sync through `events.list`. With no token, a full read of the window (open-ended when
|
|
1175
|
+
* the window is not given); with one, only what changed. Recurring events are expanded
|
|
1176
|
+
* (`singleEvents`), on every request, since Google wants the same parameters on the incremental
|
|
1177
|
+
* reads as on the full one. Pages are followed until the `nextSyncToken`. An expired token is a
|
|
1178
|
+
* 410 Gone: that comes back as `ResyncRequired`.
|
|
1179
|
+
*/
|
|
1180
|
+
async SyncEvents(params, credentials) {
|
|
1181
|
+
const failure = (message, err) => ({
|
|
1182
|
+
Success: false,
|
|
1183
|
+
Changes: [],
|
|
1184
|
+
ErrorMessage: message,
|
|
1185
|
+
ResyncRequired: !!params.SyncToken && this.httpStatus(err) === 410
|
|
1186
|
+
});
|
|
1187
|
+
return this.calendarCall('SyncEvents', params.Target, credentials, failure, async (calendar, calendarId) => {
|
|
1188
|
+
const result = { Success: true, Changes: [] };
|
|
1189
|
+
let pageToken;
|
|
1190
|
+
for (let page = 0; page < GOOGLE_MAX_SYNC_PAGES; page++) {
|
|
1191
|
+
const response = await calendar.events.list(this.syncListParams(calendarId, params, pageToken));
|
|
1192
|
+
result.Changes.push(...(response.data.items ?? []).map(ToCalendarChange));
|
|
1193
|
+
pageToken = response.data.nextPageToken ?? undefined;
|
|
1194
|
+
if (!pageToken) {
|
|
1195
|
+
result.NextSyncToken = response.data.nextSyncToken ?? undefined;
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return result.NextSyncToken ? result : failure('Google Calendar sync ended without a nextSyncToken; nothing was applied.');
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
/** The `events.list` parameters for one page. A sync token excludes the window, as Google requires. */
|
|
1203
|
+
syncListParams(calendarId, params, pageToken) {
|
|
1204
|
+
return {
|
|
1205
|
+
calendarId,
|
|
1206
|
+
singleEvents: true,
|
|
1207
|
+
...(params.SyncToken
|
|
1208
|
+
? { syncToken: params.SyncToken }
|
|
1209
|
+
: {
|
|
1210
|
+
...(params.WindowStart ? { timeMin: params.WindowStart.toISOString() } : {}),
|
|
1211
|
+
...(params.WindowEnd ? { timeMax: params.WindowEnd.toISOString() } : {})
|
|
1212
|
+
}),
|
|
1213
|
+
...(pageToken ? { pageToken } : {})
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* The shared shape of every calendar call: refuse a missing target before any I/O, build the
|
|
1218
|
+
* calendar client from the account's OAuth2 client, and turn a thrown error into a result.
|
|
1219
|
+
*/
|
|
1220
|
+
async calendarCall(operation, target, credentials, failure, work) {
|
|
1221
|
+
const refusal = CalendarTargetError(operation, target);
|
|
1222
|
+
if (refusal || !target)
|
|
1223
|
+
return failure(refusal ?? `${operation} needs a Target.`);
|
|
1224
|
+
try {
|
|
1225
|
+
const cached = this.getGmailClient(this.resolveCredentials(credentials));
|
|
1226
|
+
cached.calendar ??= googleApis.google.calendar({ version: 'v3', auth: cached.auth });
|
|
1227
|
+
return await work(cached.calendar, target.CalendarID?.trim() || target.Identifier.trim());
|
|
1228
|
+
}
|
|
1229
|
+
catch (err) {
|
|
1230
|
+
return failure(`Google Calendar ${operation} failed for ${target.Identifier}: ${err instanceof Error ? err.message : String(err)}`, err);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
/** The HTTP status on a googleapis (Gaxios) error: `.code`, `.status` or `.response.status`. */
|
|
1234
|
+
httpStatus(error) {
|
|
1235
|
+
if (typeof error !== 'object' || error === null)
|
|
1236
|
+
return undefined;
|
|
1237
|
+
const err = error;
|
|
1238
|
+
const status = Number(err.response?.status ?? err.status ?? err.code);
|
|
1239
|
+
return Number.isFinite(status) ? status : undefined;
|
|
1240
|
+
}
|
|
666
1241
|
// ========================================================================
|
|
667
1242
|
// PUSH-NOTIFICATION SUBSCRIPTIONS
|
|
668
1243
|
// Gmail is SUBSCRIPTION-MANAGED and HINT-mode. A mailbox `users.watch` registration
|