diff --git a/docs/pr-assets/calendar-invite-editor/01-calendar-day-view.png b/docs/pr-assets/calendar-invite-editor/01-calendar-day-view.png new file mode 100644 index 00000000..62956970 Binary files /dev/null and b/docs/pr-assets/calendar-invite-editor/01-calendar-day-view.png differ diff --git a/docs/pr-assets/calendar-invite-editor/02-invite-editor-filled.png b/docs/pr-assets/calendar-invite-editor/02-invite-editor-filled.png new file mode 100644 index 00000000..0d78523d Binary files /dev/null and b/docs/pr-assets/calendar-invite-editor/02-invite-editor-filled.png differ diff --git a/docs/pr-assets/calendar-invite-editor/03-write-permission-reauth.png b/docs/pr-assets/calendar-invite-editor/03-write-permission-reauth.png new file mode 100644 index 00000000..85cbb125 Binary files /dev/null and b/docs/pr-assets/calendar-invite-editor/03-write-permission-reauth.png differ diff --git a/src/extensions/mail-ext-calendar/package.json b/src/extensions/mail-ext-calendar/package.json index df2db44b..cb34cae6 100644 --- a/src/extensions/mail-ext-calendar/package.json +++ b/src/extensions/mail-ext-calendar/package.json @@ -15,7 +15,8 @@ "id": "day-view", "title": "Calendar", "priority": 100, - "scope": "email" + "scope": "email", + "ownHeader": true } ], "settings": [ diff --git a/src/extensions/mail-ext-calendar/src/google-calendar-client.ts b/src/extensions/mail-ext-calendar/src/google-calendar-client.ts index 3e9372c1..3f358282 100644 --- a/src/extensions/mail-ext-calendar/src/google-calendar-client.ts +++ b/src/extensions/mail-ext-calendar/src/google-calendar-client.ts @@ -8,6 +8,7 @@ import { readFile, readdir } from "fs/promises"; import { existsSync } from "fs"; import { join } from "path"; import { getDataDir } from "../../../main/data-dir"; +import type { CalendarEventInsertParams } from "../../../shared/types"; export interface CalendarEvent { id: string; @@ -26,6 +27,10 @@ export interface CalendarInfo { id: string; name: string; color: string; + timezone?: string; + primary?: boolean; + accessRole?: string; + writable: boolean; } /** Result of an incremental or full sync for a single calendar. */ @@ -92,14 +97,46 @@ async function getOAuth2Client(accountId: string): Promise } } +function tokenScopes(auth: OAuth2Client): Set { + const scopes = auth.credentials.scope || ""; + return new Set(scopes.split(/\s+/).filter(Boolean)); +} + +const CALENDAR_READ_SCOPES = new Set([ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/calendar", +]); + +const CALENDAR_WRITE_SCOPES = new Set([ + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/calendar", +]); + /** - * Check if the current tokens include calendar scope. + * Check if the current tokens include any calendar read scope. */ export async function hasCalendarScope(accountId: string): Promise { const auth = await getOAuth2Client(accountId); if (!auth) return false; - const scopes = auth.credentials.scope || ""; - return scopes.includes("calendar.readonly") || scopes.includes("calendar"); + const scopes = tokenScopes(auth); + return Array.from(CALENDAR_READ_SCOPES).some((scope) => scopes.has(scope)); +} + +/** Does this already-loaded client's token grant calendar write scope? */ +function authHasWriteScope(auth: OAuth2Client): boolean { + const scopes = tokenScopes(auth); + return Array.from(CALENDAR_WRITE_SCOPES).some((scope) => scopes.has(scope)); +} + +/** + * Check if the current tokens include Google Calendar event write scope. + */ +export async function hasCalendarWriteScope(accountId: string): Promise { + const auth = await getOAuth2Client(accountId); + if (!auth) return false; + return authHasWriteScope(auth); } /** @@ -166,19 +203,52 @@ export async function getCalendarList(accountId: string): Promise { + const auth = await getOAuth2Client(accountId); + if (!auth) { + throw new Error("Calendar account is not authenticated"); + } + + const calendar = google.calendar({ version: "v3", auth }); + const response = await calendar.events.insert({ + calendarId: params.calendarId, + sendUpdates: params.sendUpdates, + conferenceDataVersion: params.conferenceDataVersion, + requestBody: params.requestBody, + }); + + const event = parseCalendarEvent(response.data, calInfo); + if (!event) { + throw new Error("Google Calendar did not return a created event"); + } + return event; +} + /** * Sync calendar events using Google's sync token mechanism. * diff --git a/src/extensions/mail-ext-calendar/src/renderer/CalendarPanel.tsx b/src/extensions/mail-ext-calendar/src/renderer/CalendarPanel.tsx index 5c285d38..02d10ecc 100644 --- a/src/extensions/mail-ext-calendar/src/renderer/CalendarPanel.tsx +++ b/src/extensions/mail-ext-calendar/src/renderer/CalendarPanel.tsx @@ -1,6 +1,26 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; -import type { DashboardEmail } from "../../../../shared/types"; +import { + CALENDAR_INVITE_EXTRACTION_FAILURE_WARNING, + validateCalendarInviteDraft, +} from "../../../../shared/calendar-invite"; +import type { + CalendarInviteCalendarOption, + CalendarInviteDraft, + DashboardEmail, +} from "../../../../shared/types"; import type { ExtensionEnrichmentResult } from "../../../../shared/extension-types"; +import { useAppStore } from "../../../../renderer/store"; +import { + addDays, + shouldStartInviteExtraction, + todayString, + toDateInput, + toTimeInput, + updateInviteEndTime, + updateInviteStartDate, + updateInviteStartTime, +} from "../../../../shared/calendar-invite-editor"; +import { wallClockToInstant } from "../../../../shared/calendar-timezone"; // --------------------------------------------------------------------------- // Types @@ -36,9 +56,41 @@ interface GetEventsResponse { interface CalendarApi { getEvents: (d: string) => Promise; + getInviteOptions: () => Promise; + extractInvite: (emailId: string) => Promise; + createInvite: (accountId: string, draft: CalendarInviteDraft) => Promise; onEventsUpdated: (callback: () => void) => () => void; } +interface InviteOptionsResponse { + success: boolean; + calendars?: CalendarInviteCalendarOption[]; + hasWriteAccess?: boolean; + requiresReauth?: boolean; + error?: string; +} + +interface ExtractInviteResponse { + success: boolean; + draft?: CalendarInviteDraft; + calendars?: CalendarInviteCalendarOption[]; + hasWriteAccess?: boolean; + requiresReauth?: boolean; + hasCalendarAccess?: boolean; + error?: string; +} + +interface CreateInviteResponse { + success: boolean; + event?: CalendarEvent; + error?: string; + validationErrors?: string[]; + requiresReauth?: boolean; +} + +type InviteStatus = "idle" | "extracting" | "ready" | "creating"; +type ReauthResponse = { success: boolean; error?: string }; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -54,23 +106,6 @@ const MIN_EVENT_HEIGHT = 20; // Helpers // --------------------------------------------------------------------------- -function toDateString(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - -function todayString(): string { - return toDateString(new Date()); -} - -function addDays(dateStr: string, n: number): string { - const d = new Date(dateStr + "T12:00:00"); // noon avoids DST edge - d.setDate(d.getDate() + n); - return toDateString(d); -} - function formatHeaderDate(dateStr: string): string { const d = new Date(dateStr + "T12:00:00"); return d.toLocaleDateString("en-US", { @@ -102,6 +137,77 @@ function toFractionalHour(isoStr: string): number { return d.getHours() + d.getMinutes() / 60; } +function blankInviteDraft(timezone: string): CalendarInviteDraft { + return { + title: "", + start: "", + end: "", + timezone, + guests: [], + conference: { type: "googleMeet" }, + location: "", + description: "", + calendarId: "", + confidence: 0, + warnings: [], + }; +} + +function guestsToInput(guests: string[]): string { + return guests.join(", "); +} + +function inputToGuests(value: string): string[] { + return Array.from( + new Set( + value + .split(/[,\n]/) + .map((guest) => guest.trim()) + .filter(Boolean), + ), + ); +} + +function calendarKey(option: CalendarInviteCalendarOption): string { + return `${option.accountId}::${option.calendarId}`; +} + +function preferredCalendarOption( + calendars: CalendarInviteCalendarOption[], + accountId: string, + calendarId: string, +): CalendarInviteCalendarOption | undefined { + const preferredKey = accountId && calendarId ? `${accountId}::${calendarId}` : ""; + const accountCalendars = accountId + ? calendars.filter((calendar) => calendar.accountId === accountId) + : []; + return ( + calendars.find((calendar) => preferredKey && calendarKey(calendar) === preferredKey) ?? + accountCalendars.find((calendar) => calendar.writable && calendar.primary) ?? + accountCalendars.find((calendar) => calendar.writable) ?? + accountCalendars[0] ?? + calendars.find((calendar) => calendar.writable && calendar.primary) ?? + calendars.find((calendar) => calendar.writable) ?? + calendars[0] + ); +} + +function toReauthResponse(value: unknown): ReauthResponse { + if (!value || typeof value !== "object") { + return { success: false, error: "Unexpected re-authentication response" }; + } + + const response = value as Record; + return { + success: response.success === true, + error: typeof response.error === "string" ? response.error : undefined, + }; +} + +function isConferenceType(value: string): value is CalendarInviteDraft["conference"]["type"] { + return value === "googleMeet" || value === "link" || value === "phone" || value === "none"; +} + // --------------------------------------------------------------------------- // Overlap layout — assign columns to overlapping events // --------------------------------------------------------------------------- @@ -194,6 +300,9 @@ function EventBlock({ event, layoutInfo }: { event: CalendarEvent; layoutInfo: L return (