diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88d23af7..aee24482 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,16 @@ on: push: tags: ['v*'] workflow_dispatch: + inputs: + version: + description: 'Release tag to build and publish, e.g. v0.14.1 or v0.14.1-beta.1' + required: true + type: string + prerelease: + description: 'Mark this GitHub Release as a prerelease' + required: false + default: false + type: boolean permissions: contents: write @@ -35,10 +45,20 @@ jobs: - name: Run unit tests run: npm run test:unit - - name: Set version from git tag - if: startsWith(github.ref, 'refs/tags/v') + - name: Set version from release tag + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + env: + DISPATCH_VERSION: ${{ inputs.version }} run: | - VERSION="${GITHUB_REF_NAME#v}" + TAG="$GITHUB_REF_NAME" + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + TAG="$DISPATCH_VERSION" + fi + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-\.][0-9A-Za-z.-]+)?$ ]]; then + echo "ERROR: Release tag must look like v0.14.1 or v0.14.1-beta.1; got $TAG" + exit 1 + fi + VERSION="${TAG#v}" # For stable tags (exactly X.Y.Z with no suffix), zero-pad to 3 parts if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then IFS='.' read -ra PARTS <<< "$VERSION" @@ -255,7 +275,7 @@ jobs: if: | needs.build-and-sign.result == 'success' && needs.staple.result == 'success' && - startsWith(github.ref, 'refs/tags/v') + (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest steps: - name: Download stapled artifacts @@ -267,18 +287,28 @@ jobs: - name: Upload assets to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DISPATCH_VERSION: ${{ inputs.version }} + DISPATCH_PRERELEASE: ${{ inputs.prerelease }} run: | + TAG="$GITHUB_REF_NAME" + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + TAG="$DISPATCH_VERSION" + fi + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-\.][0-9A-Za-z.-]+)?$ ]]; then + echo "ERROR: Release tag must look like v0.14.1 or v0.14.1-beta.1; got $TAG" + exit 1 + fi # Detect pre-release tags (e.g. v1.2.3-beta.1, v1.2.3-rc.1) PRERELEASE_FLAG="" LATEST_FLAG="" - if [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-.+ ]]; then + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-.+ ]] || [ "$DISPATCH_PRERELEASE" = "true" ]; then PRERELEASE_FLAG="--prerelease" LATEST_FLAG="--latest=false" fi - gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" 2>/dev/null || \ - gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --generate-notes $PRERELEASE_FLAG $LATEST_FLAG + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" 2>/dev/null || \ + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "$TAG" --generate-notes $PRERELEASE_FLAG $LATEST_FLAG # Ensure flags are set even if the release already existed if [[ -n "$PRERELEASE_FLAG" ]]; then - gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --prerelease --latest=false + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --prerelease --latest=false fi - gh release upload "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" release/* --clobber + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" release/* --clobber 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..eb6a112a 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..dbd9c34b 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..98a5eebf Binary files /dev/null and b/docs/pr-assets/calendar-invite-editor/03-write-permission-reauth.png differ diff --git a/docs/pr-assets/calendar-invite-editor/calendar-invite-editor.gif b/docs/pr-assets/calendar-invite-editor/calendar-invite-editor.gif new file mode 100644 index 00000000..697f8a87 Binary files /dev/null and b/docs/pr-assets/calendar-invite-editor/calendar-invite-editor.gif differ diff --git a/docs/superpowers/specs/2026-05-28-calendar-invite-editor-design.md b/docs/superpowers/specs/2026-05-28-calendar-invite-editor-design.md new file mode 100644 index 00000000..c7b25901 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-calendar-invite-editor-design.md @@ -0,0 +1,118 @@ +# Calendar Invite Editor Design + +## Goal + +Add a Superhuman-style calendar invite creation flow for selected email threads. The user can trigger the flow from the keyboard or command palette, review AI-extracted event details in the Calendar tab, see the proposed event against their day view, edit fields, and create/send the Google Calendar invite. + +## User Flow + +When an email thread is selected, the user can press `i` or choose `Create calendar invite` from the `Cmd+K` command palette. Exo switches the right sidebar to the Calendar tab and enters a `new invite` mode. + +The Calendar tab first shows an extraction/loading state while AI reads the selected thread. When extraction completes, Exo shows an editable event form above the day view. The proposed event appears as a temporary block in the day view before it exists in Google Calendar. Editing the date, start time, end time, or duration updates the proposed block live so the user can see whether they are free. + +The primary action is `Create and send`. It creates the Google Calendar event and sends invitations to guests immediately after the user has reviewed the form. `Cancel` or `Esc` exits invite mode without creating anything. + +## UI Design + +The invite editor lives inside the existing Calendar sidebar tab rather than as a separate temporary panel. This keeps event creation next to the availability view. + +The top form uses compact icon-led rows: + +- Title +- Guests +- Date and time +- Video conferencing or meeting link +- Location +- Agenda or notes +- Calendar selector + +Below the form, the existing day view remains visible. Existing events render normally. The proposed invite renders as a distinct temporary event block using the extracted title and time. + +The editor should fit the right rail without feeling cramped. If needed, the form and day view can share a scrollable column, with the final action bar pinned at the bottom. + +## Extraction Behavior + +AI extraction is required for all event fields. The extractor receives the selected thread subject, participants, and message bodies, then returns a strict structured draft: + +- `title` +- `start` +- `end` +- `timezone` +- `guests` +- `conference` +- `location` +- `description` +- `calendarId` +- `confidence` +- `warnings` + +The agent should infer guests from thread context. It should not blindly include every sender, To recipient, and CC recipient. + +Google Meet is the default conference option unless the thread clearly contains another meeting link, video provider, or physical location. If the thread includes a Zoom link, Meet link, phone bridge, or address, the extracted draft should preserve that instead of overwriting it with Google Meet. + +Timezone should come from the user’s selected Google calendar when available, especially the primary/default calendar. If the calendar timezone is not available, fallback to `Intl.DateTimeFormat().resolvedOptions().timeZone`. The AI may identify an explicitly mentioned timezone, but the editor should normalize and display the event in the user calendar timezone. + +Missing or uncertain values should not prevent the panel from opening. The UI should show blanks and inline warnings so the user can complete the event manually. + +## Calendar Creation + +The MVP supports Google Calendar only. + +Creating an invite uses Google Calendar event creation with guest notifications enabled. The event creation request should include: + +- selected calendar ID +- summary/title +- start/end with timezone +- attendees +- description +- location +- Google Meet conference creation when selected +- `sendUpdates: "all"` so guests receive invitations immediately + +After creation succeeds, Exo should refresh calendar state and exit edit mode so the created event appears in the day view. + +## Permissions + +The existing calendar integration is read-only. This feature needs Google Calendar write permissions for event creation. Existing users may need to re-authenticate Google once after the scope change. + +The UI should handle missing write permission clearly: show an inline error with a re-authenticate action rather than failing silently. + +## Error Handling + +Warnings should be inline and reviewable: + +- missing title +- missing start/end time +- ambiguous duration +- no guests +- no write-capable Google Calendar account +- failed AI extraction +- failed Google Meet creation +- failed event creation + +Only final creation should be blocked by missing required fields or missing write permission. Extraction failures should leave the user in an editable blank invite form when possible. + +## Testing + +Implementation should include focused tests for: + +- AI extractor schema parsing and fallback warnings +- timezone selection from Google calendar metadata with local fallback +- event creation payload generation +- `sendUpdates: "all"` behavior +- Google Meet conference payload behavior +- missing-field validation before final creation +- `i` shortcut and `Cmd+K` command availability +- Calendar tab editor rendering proposed event alongside existing events +- error states for extraction failure and calendar creation failure + +## Non-Goals + +This feature does not include: + +- non-Google calendar providers +- editing or deleting existing calendar events +- sending invites without a review screen +- automatic scheduling optimization across multiple proposed times +- RSVP management +- background invite creation from the agent without the Calendar tab editor diff --git a/package.json b/package.json index 642f5863..03642364 100644 --- a/package.json +++ b/package.json @@ -159,8 +159,8 @@ ], "publish": { "provider": "github", - "owner": "ankitvgupta", - "repo": "mail-app", + "owner": "mickn", + "repo": "exo", "private": true }, "directories": { 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..e5f7e5cc 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,41 @@ 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)); +} + +/** + * 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; + const scopes = tokenScopes(auth); + return Array.from(CALENDAR_WRITE_SCOPES).some((scope) => scopes.has(scope)); } /** @@ -149,6 +181,17 @@ export async function findAllCalendarAccounts(): Promise { return result; } +export async function findAllCalendarWriteAccounts(): Promise { + const accounts = await findAllCalendarAccounts(); + const writable: string[] = []; + for (const accountId of accounts) { + if (await hasCalendarWriteScope(accountId)) { + writable.push(accountId); + } + } + return writable; +} + /** * Find any account that has calendar scope (backwards-compatible). */ @@ -166,19 +209,50 @@ 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..da98d961 100644 --- a/src/extensions/mail-ext-calendar/src/renderer/CalendarPanel.tsx +++ b/src/extensions/mail-ext-calendar/src/renderer/CalendarPanel.tsx @@ -1,6 +1,12 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; -import type { DashboardEmail } from "../../../../shared/types"; +import { 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"; // --------------------------------------------------------------------------- // Types @@ -36,9 +42,37 @@ 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; + 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 // --------------------------------------------------------------------------- @@ -102,6 +136,96 @@ 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 toDateInput(isoStr: string): string { + if (!isoStr) return ""; + const d = new Date(isoStr); + if (!Number.isFinite(d.getTime())) return ""; + return toDateString(d); +} + +function toTimeInput(isoStr: string): string { + if (!isoStr) return ""; + const d = new Date(isoStr); + if (!Number.isFinite(d.getTime())) return ""; + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +function combineDateAndTime(dateStr: string, timeStr: string): string { + if (!dateStr || !timeStr) return ""; + return new Date(`${dateStr}T${timeStr}:00`).toISOString(); +} + +function addMinutes(isoStr: string, minutes: number): string { + const d = new Date(isoStr); + if (!Number.isFinite(d.getTime())) return ""; + return new Date(d.getTime() + minutes * 60_000).toISOString(); +} + +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}` : ""; + return ( + calendars.find((calendar) => preferredKey && calendarKey(calendar) === preferredKey) ?? + 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 +318,9 @@ function EventBlock({ event, layoutInfo }: { event: CalendarEvent; layoutInfo: L return (