Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion apps/web/components/apps/CalendarListContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Switch } from "@calcom/ui/components/form";
import { ShellSubHeading } from "@calcom/ui/components/layout";
import { List } from "@calcom/ui/components/list";
import { showToast } from "@calcom/ui/components/toast";
Expand Down Expand Up @@ -101,14 +102,47 @@ export function CalendarListContainer({
const { t } = useLocale();
const { error, setQuery: setError } = useRouterQuery("error");

const { data: user, isLoading: isUserLoading } = trpc.viewer.me.get.useQuery();
const utils = trpc.useUtils();

const updateProfileMutation = trpc.viewer.me.updateProfile.useMutation({
onMutate: async (newData) => {
await utils.viewer.me.get.cancel();
const previousData = utils.viewer.me.get.getData();
utils.viewer.me.get.setData(undefined, (old) => {
if (!old) return old;
return { ...old, notifyCalendarAlerts: newData.notifyCalendarAlerts ?? old.notifyCalendarAlerts };
});
return { previousData };
},
onSuccess: () => {
showToast(t("settings_updated_successfully"), "success");
},
onError: (error, _newData, context) => {
showToast(error.message, "error");
if (context?.previousData) {
utils.viewer.me.get.setData(undefined, context.previousData);
}
},
onSettled: () => {
utils.viewer.me.get.invalidate();
},
});

const handleCalendarNotificationToggle = (enabled: boolean) => {
updateProfileMutation.mutate({
notifyCalendarAlerts: enabled,
});
};

useEffect(() => {
if (error === "account_already_linked" || error === "no_default_calendar") {
showToast(t(error), "error", { id: error });
setError(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const utils = trpc.useUtils();

const onChanged = (): void => {
Promise.allSettled([
utils.viewer.apps.integrations.invalidate(
Expand Down Expand Up @@ -183,6 +217,28 @@ export function CalendarListContainer({
description={t("calendars_description")}
CTA={<AddCalendarButton />}>
{content}

{/* Calendar Notifications Section */}
<div className="border-subtle mt-8 rounded-b-xl border-x border-b px-4 pb-8 pt-6 sm:px-6">
<ShellSubHeading
title={t("calendar_notifications")}
subtitle={t("calendar_notifications_description")}
/>
<div className="mt-4 flex items-center justify-between">
<div className="flex-grow">
<h3 className="text-emphasis text-sm font-medium leading-6">
{t("unreachable_calendar_alerts")}
</h3>
<p className="text-subtle mt-1 text-sm">{t("unreachable_calendar_alerts_description")}</p>
</div>
<Switch
checked={Boolean(user?.notifyCalendarAlerts ?? true)}
onCheckedChange={handleCalendarNotificationToggle}
disabled={isUserLoading || updateProfileMutation.isPending}
aria-label={t("unreachable_calendar_alerts")}
/>
</div>
</div>
</SettingsHeader>
);
}
4 changes: 4 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,10 @@
"please_allow_notifications": "Please allow notifications from the prompt",
"push_notifications": "Push notifications",
"push_notifications_description": "Receive push notifications when booker submits instant meeting booking.",
"calendar_notifications": "Calendar Notifications",
"calendar_notifications_description": "Configure notifications for calendar connection issues.",
"unreachable_calendar_alerts": "Unreachable Calendar Alerts",
"unreachable_calendar_alerts_description": "Get notified when your connected calendars become inaccessible due to authentication issues.",
"browser_notifications_not_supported": "Your browser does not support Push Notifications. If you are Brave user then enable `Use Google services for push messaging` Option on brave://settings/?search=push+messaging",
"email": "Email",
"email_placeholder": "jdoe@example.com",
Expand Down
4 changes: 4 additions & 0 deletions packages/app-store/_utils/invalidateCredential.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { markCredentialAsUnreachable } from "@calcom/lib/markCredentialAsUnreachable";
import prisma from "@calcom/prisma";
import type { CredentialPayload } from "@calcom/types/Credential";

Expand All @@ -17,5 +18,8 @@ export const invalidateCredential = async (credentialId: CredentialPayload["id"]
invalid: true,
},
});

// Also mark as unreachable and send notification if appropriate
await markCredentialAsUnreachable(credentialId, "Credential invalidated due to authentication failure");
}
};
53 changes: 53 additions & 0 deletions packages/app-store/googlecalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MeetLocationType } from "@calcom/app-store/constants";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { markCredentialAsReachable } from "@calcom/lib/markCredentialAsUnreachable";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getDestinationCalendarRepository } from "@calcom/features/di/containers/DestinationCalendar";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
Expand Down Expand Up @@ -445,6 +446,10 @@ class GoogleCalendarService implements Calendar {
const apiResponse = await this.auth.authManager.request(
async () => new AxiosLikeResponseToFetchResponse(await calendar.freebusy.query({ requestBody }))
);

// Mark credential as reachable on successful API call
await markCredentialAsReachable(this.credential.id);

return apiResponse.json;
}

Expand Down Expand Up @@ -671,6 +676,16 @@ class GoogleCalendarService implements Calendar {
const calendarIds = await this.getCalendarIds(selectedCalendarIds, fallbackToPrimary);
return await this.fetchAvailabilityData(calendarIds, dateFrom, dateTo);
} catch (error) {
// Check if this is an authentication error (401, 403, or invalid_grant)
if (this.isAuthenticationError(error)) {
this.log.warn("Authentication error detected, marking credential as unreachable", {
credentialId: this.credential.id,
error: error instanceof Error ? error.message : String(error),
});
// Note: markCredentialAsUnreachable will be called via invalidateCredential
// which is already called by the OAuthManager when token issues are detected
}

this.log.error(
"There was an error getting availability from google calendar: ",
safeStringify({ error, selectedCalendars })
Expand Down Expand Up @@ -824,6 +839,44 @@ class GoogleCalendarService implements Calendar {
throw error;
}
}

/**
* Checks if an error is an authentication-related error (401, 403, invalid_grant, etc.)
*/
private isAuthenticationError(error: unknown): boolean {
// Check for HTTP status codes
if (error && typeof error === "object") {
// Handle GaxiosResponse errors
if ("status" in error && typeof error.status === "number") {
return error.status === 401 || error.status === 403;
}

// Handle Response objects
if ("status" in error && typeof error.status === "number") {
return error.status === 401 || error.status === 403;
}

// Handle Google API specific errors
if ("message" in error && typeof error.message === "string") {
const message = error.message.toLowerCase();
return (
message.includes("invalid_grant") ||
message.includes("unauthorized") ||
message.includes("forbidden") ||
message.includes("invalid credentials") ||
message.includes("token expired") ||
message.includes("access denied")
);
}

// Handle errors with code property
if ("code" in error && typeof error.code === "number") {
return error.code === 401 || error.code === 403;
}
}

return false;
}
}

/**
Expand Down
23 changes: 23 additions & 0 deletions packages/emails/email-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import OrganizerRequestReminderEmail from "./templates/organizer-request-reminde
import OrganizerRequestedToRescheduleEmail from "./templates/organizer-requested-to-reschedule-email";
import OrganizerRescheduledEmail from "./templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
import { UnreachableCalendarEmail } from "./templates/unreachable-calendar-email";

type EventTypeMetadata = z.infer<typeof EventTypeMetaDataSchema>;

Expand Down Expand Up @@ -768,3 +769,25 @@ export const sendAddGuestsEmailsAndSMS = async (args: {

await Promise.all(emailsAndSMSToSend);
};

export const sendUnreachableCalendarEmail = async ({
recipientEmail,
recipientName,
calendarName,
reason,
}: {
recipientEmail: string;
recipientName?: string;
calendarName: string;
reason: string;
}) => {
await sendEmail(
() =>
new UnreachableCalendarEmail({
recipientEmail,
recipientName,
calendarName,
reason,
})
);
};
Comment on lines +773 to +793
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Sanitize dynamic values used in the email template (subject/body) to prevent HTML/header injection

calendarName, recipientName, and especially reason may contain provider/error strings. Ensure they’re HTML-escaped in the template (and header-safe in subject). If not handled in the template, add escaping there.

Below change applies to packages/emails/templates/unreachable-calendar-email.ts:

@@
 export default class UnreachableCalendarEmail extends BaseEmail {
@@
-  protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
+  private esc(s?: string) {
+    if (!s) return "";
+    return String(s)
+      .replace(/&/g, "&amp;")
+      .replace(/</g, "&lt;")
+      .replace(/>/g, "&gt;")
+      .replace(/"/g, "&quot;")
+      .replace(/'/g, "&#39;");
+  }
+
+  protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
     return {
-      from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
-      to: this.recipientEmail,
-      subject: `Action needed: Your ${this.calendarName} is unreachable`,
+      from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
+      to: this.recipientEmail,
+      subject: `Action needed: Your ${this.esc(this.calendarName)} is unreachable`,
       html: await this.getHtml(),
       text: this.getTextBody(),
     };
   }
 
   async getHtml() {
     return `
       <div style="font-family: Arial, sans-serif;">
-        <h2>Action Required: Your ${this.calendarName} is unreachable</h2>
+        <h2>Action Required: Your ${this.esc(this.calendarName)} is unreachable</h2>
         <p>
-          ${this.recipientName ? `Hi ${this.recipientName},` : "Hello,"}
+          ${this.recipientName ? `Hi ${this.esc(this.recipientName)},` : "Hello,"}
         </p>
         <p>
-          Your linked calendar <strong>${this.calendarName}</strong> is no longer reachable. 
-          ${this.reason ? `Reason: ${this.reason}.` : ""} 
+          Your linked calendar <strong>${this.esc(this.calendarName)}</strong> is no longer reachable. 
+          ${this.reason ? `Reason: ${this.esc(this.reason)}.` : ""} 
           Until you reconnect or remove it, invitees will see no availability on your booking pages.
         </p>
@@
   protected getTextBody(): string {
-    return `Action Required: Your ${this.calendarName} is unreachable
+    return `Action Required: Your ${this.calendarName} is unreachable
 
-${this.recipientName ? `Hi ${this.recipientName},` : "Hello,"}
+${this.recipientName ? `Hi ${this.recipientName},` : "Hello,"}
 
 Your linked calendar ${this.calendarName} is no longer reachable. ${
       this.reason ? `Reason: ${this.reason}.` : ""
     } Until you reconnect or remove it, invitees will see no availability on your booking pages.

Optionally, also escape values in the text body for consistency, though risk is lower there.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const sendUnreachableCalendarEmail = async ({
recipientEmail,
recipientName,
calendarName,
reason,
}: {
recipientEmail: string;
recipientName?: string;
calendarName: string;
reason: string;
}) => {
await sendEmail(
() =>
new UnreachableCalendarEmail({
recipientEmail,
recipientName,
calendarName,
reason,
})
);
};
export default class UnreachableCalendarEmail extends BaseEmail {
// Escape helper to prevent HTML/header injection
private esc(s?: string): string {
if (!s) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.recipientEmail,
subject: `Action needed: Your ${this.esc(this.calendarName)} is unreachable`,
html: await this.getHtml(),
text: this.getTextBody(),
};
}
async getHtml(): Promise<string> {
return `
<div style="font-family: Arial, sans-serif;">
<h2>Action Required: Your ${this.esc(this.calendarName)} is unreachable</h2>
<p>
${this.recipientName ? `Hi ${this.esc(this.recipientName)},` : "Hello,"}
</p>
<p>
Your linked calendar <strong>${this.esc(this.calendarName)}</strong> is no longer reachable.
${this.reason ? `Reason: ${this.esc(this.reason)}.` : ""}
Until you reconnect or remove it, invitees will see no availability on your booking pages.
</p>
</div>
`;
}
protected getTextBody(): string {
return `Action Required: Your ${this.calendarName} is unreachable
${this.recipientName ? `Hi ${this.recipientName},` : "Hello,"}
Your linked calendar ${this.calendarName} is no longer reachable. ${
this.reason ? `Reason: ${this.reason}.` : ""
} Until you reconnect or remove it, invitees will see no availability on your booking pages.
`;
}
}

121 changes: 121 additions & 0 deletions packages/emails/templates/unreachable-calendar-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { EMAIL_FROM_NAME, WEBAPP_URL } from "@calcom/lib/constants";

import BaseEmail from "./_base-email";

export class UnreachableCalendarEmail extends BaseEmail {
recipientEmail: string;
recipientName?: string;
calendarName: string;
reason: string;

private esc(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

constructor({
recipientEmail,
recipientName,
calendarName,
reason,
}: {
recipientEmail: string;
recipientName?: string;
calendarName: string;
reason: string;
}) {
super();
this.name = "UNREACHABLE_CALENDAR";
this.recipientEmail = recipientEmail;
this.recipientName = recipientName;
this.calendarName = calendarName;
this.reason = reason;
}

protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.recipientEmail,
subject: `Action needed: Your ${this.calendarName} is unreachable`,
html: await this.getHtml(),
text: this.getTextBody(),
};
}

async getHtml() {
const escapedCalendarName = this.esc(this.calendarName);
const escapedRecipientName = this.recipientName ? this.esc(this.recipientName) : undefined;
const escapedReason = this.reason ? this.esc(this.reason) : undefined;

return `
<div style="font-family: Arial, sans-serif;">
<h2>Action Required: Your ${escapedCalendarName} is unreachable</h2>
<p>
${escapedRecipientName ? `Hi ${escapedRecipientName},` : "Hello,"}
</p>
<p>
Your linked calendar <strong>${escapedCalendarName}</strong> is no longer reachable.
${escapedReason ? `Reason: ${escapedReason}.` : ""}
Until you reconnect or remove it, invitees will see no availability on your booking pages.
</p>
<p>
<strong>What this means:</strong>
</p>
<ul>
<li>Your booking links will show no available time slots</li>
<li>Potential meetings may be missed</li>
<li>Cal.com cannot check for conflicts or add new events to your calendar</li>
</ul>
<p>
<strong>How to fix this:</strong>
</p>
<p>
Please log in to your Cal.com account and reconnect your calendar from your settings page:
<br />
<a href="${WEBAPP_URL}/settings/my-account/calendars" style="color: #3b82f6; text-decoration: none;">
${WEBAPP_URL}/settings/my-account/calendars
</a>
</p>
<p>
If you no longer use this calendar, you can safely remove it from your Cal.com settings.
</p>
<p>
If you have already reconnected your calendar, you can ignore this message.
</p>
<p>
Thank you,<br />
The Cal.com Team
</p>
</div>
`;
}

protected getTextBody(): string {
return `Action Required: Your ${this.calendarName} is unreachable

${this.recipientName ? `Hi ${this.recipientName},` : "Hello,"}

Your linked calendar ${this.calendarName} is no longer reachable. ${
this.reason ? `Reason: ${this.reason}.` : ""
} Until you reconnect or remove it, invitees will see no availability on your booking pages.

What this means:
- Your booking links will show no available time slots
- Potential meetings may be missed
- Cal.com cannot check for conflicts or add new events to your calendar

How to fix this:
Please log in to your Cal.com account and reconnect your calendar from your settings page: ${WEBAPP_URL}/settings/my-account/calendars

If you no longer use this calendar, you can safely remove it from your Cal.com settings.

If you have already reconnected your calendar, you can ignore this message.

Thank you,
The Cal.com Team`;
}
}
20 changes: 20 additions & 0 deletions packages/features/credentials/repositories/CredentialRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ type CredentialUpdateInput = {
invalid?: boolean;
};

type CredentialReachabilityUpdateInput = {
id: number;
isUnreachable?: boolean;
lastNotified?: Date | null;
};

export class CredentialRepository {
constructor(private prismaClient: PrismaClient) {}

Expand Down Expand Up @@ -131,6 +137,20 @@ export class CredentialRepository {
});
}

static async updateReachabilityById({
id,
isUnreachable,
lastNotified,
}: CredentialReachabilityUpdateInput) {
await prisma.credential.update({
where: { id },
data: {
...(isUnreachable !== undefined && { isUnreachable }),
...(lastNotified !== undefined && { lastNotified }),
},
});
}

static async deleteAllByDelegationCredentialId({
delegationCredentialId,
}: {
Expand Down
Loading
Loading