Skip to content
Closed
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
48 changes: 39 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
118 changes: 118 additions & 0 deletions docs/superpowers/specs/2026-05-28-calendar-invite-editor-design.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@
],
"publish": {
"provider": "github",
"owner": "ankitvgupta",
"repo": "mail-app",
"owner": "mickn",
"repo": "exo",
"private": true
},
"directories": {
Expand Down
80 changes: 77 additions & 3 deletions src/extensions/mail-ext-calendar/src/google-calendar-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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. */
Expand Down Expand Up @@ -92,14 +97,41 @@ async function getOAuth2Client(accountId: string): Promise<OAuth2Client | null>
}
}

function tokenScopes(auth: OAuth2Client): Set<string> {
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<boolean> {
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<boolean> {
const auth = await getOAuth2Client(accountId);
if (!auth) return false;
const scopes = tokenScopes(auth);
return Array.from(CALENDAR_WRITE_SCOPES).some((scope) => scopes.has(scope));
}

/**
Expand Down Expand Up @@ -149,6 +181,17 @@ export async function findAllCalendarAccounts(): Promise<string[]> {
return result;
}

export async function findAllCalendarWriteAccounts(): Promise<string[]> {
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).
*/
Expand All @@ -166,19 +209,50 @@ export async function getCalendarList(accountId: string): Promise<CalendarInfo[]

const calendar = google.calendar({ version: "v3", auth });
const response = await calendar.calendarList.list();
const accountHasWriteScope = await hasCalendarWriteScope(accountId);
const result: CalendarInfo[] = [];
for (const cal of response.data.items || []) {
if (cal.id) {
const accessRole = cal.accessRole || "";
result.push({
id: cal.id,
name: cal.summary || "Calendar",
color: cal.backgroundColor || "#4285f4",
timezone: cal.timeZone || undefined,
primary: cal.primary || cal.id === "primary",
accessRole,
writable: accountHasWriteScope && (accessRole === "owner" || accessRole === "writer"),
});
}
}
return result;
}

export async function insertCalendarEvent(
accountId: string,
params: CalendarEventInsertParams,
calInfo: { name: string; color: string },
): Promise<CalendarEvent> {
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.
*
Expand Down
Loading