diff --git a/.claude/DECISIONS.md b/.claude/DECISIONS.md index 456907e..1c96183 100644 --- a/.claude/DECISIONS.md +++ b/.claude/DECISIONS.md @@ -4900,3 +4900,50 @@ as a side effect of a keyboard shortcut. **Applies to:** `src/hooks/use-history.ts` (new), `src/components/resume/resume-editor.tsx`, `src/components/resume/fit-panel.tsx` (its `onChange` now names what it cut). +## 2026-09-03 — Any mailbox: Microsoft 365, IMAP and CalDAV, behind one reader + +**`GoogleAccount` became `LinkedAccount`, several per person.** A `provider` column +(GOOGLE, MICROSOFT, IMAP), provider-neutral `features` ("mail", "calendar") in place of +Google's scope URLs, and the IMAP and CalDAV fields on the same row. The migration renames +rather than recreates, so a Google connection made on the previous release survives with +its scopes rewritten. Unique on (userId, provider, email) rather than userId, because a work +Outlook and a personal Gmail are both where recruiters write, and every read merges across +them with the account named on each thread and event. + +**One interface, three wire protocols.** `src/lib/accounts/types.ts` defines `MailReader` +and `CalendarReader`; `google.ts`, `microsoft.ts`, `imap.ts` and `caldav.ts` implement them +and nothing else in the app knows which answered. Read-only is enforced by the interface +having no write, not by convention. The data layer resolves credentials — refreshing OAuth +tokens, keeping Microsoft's rotated refresh token — and collects per-account failures into +`warnings` so one dead token never hides the other inbox. + +**Two libraries, on purpose, for the protocols nobody should hand-roll.** Google and +Microsoft are six HTTP requests each and stay hand-written. IMAP is a stateful protocol with +thirty years of server quirks and MIME is worse, so `imapflow` and `mailparser` do that; +CalDAV discovery differs per server and ICS recurrence is its own specification, so `tsdav` +and `ical.js` do those. They are the largest dependencies in the app and load only when an +IMAP account is read. Threads on IMAP are joined by Message-ID and In-Reply-To, which is +what every mail client does; a reply whose client dropped the header is its own thread. + +**App passwords, verified before they are stored.** `connectImapAccount` logs in to both +servers first, so a wrong password is an error in the form rather than a broken tile. +Stored as issued, like every other credential here. `connect_imap_account` exists as a tool +because MCP-first means it must, and its description says never to repeat the password. + +**A hostname in a form is a request the server makes.** `assertReachableHost` refuses +loopback, link-local (where cloud metadata lives), unspecified and multicast targets for +both IMAP and CalDAV, resolving names so an A record at 127.0.0.1 is refused like the +literal. Private ranges are allowed on purpose: a mail server on a home LAN is a real +reason to self-host, and loopback and metadata are where the damage is. + +**No SMTP.** The app never sends on anyone's behalf; read-only is what makes handing over an +inbox safe. Sending is a product decision to make on purpose, not a side effect of "support +IMAP". + +**Slack and Discord are on the picker as coming soon**, greyed, because the user asked for +them to be visible before they exist. Nothing behind them yet. + +**Applies to:** `prisma/schema.prisma`, `src/lib/accounts/`, `src/lib/data/accounts.ts` +(replacing `data/google.ts`), `src/lib/settings.ts`, `src/app/api/auth/microsoft/`, +`src/lib/mcp/{tools,handler}.ts`, `src/server/actions.ts`, `src/components/settings/`, +`src/components/admin/configuration-panel.tsx`, and the manual. diff --git a/README.md b/README.md index 7164129..c09d76f 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ just *talk* to it. before it does it. Names fold case, so `linkedin` lands on the `LinkedIn` you already have rather than minting a twin. - **AI connections** — every person gets their own URL that turns all of the above into - 122 tools any MCP client can call (152 if you're an admin). Claude, Claude Code, ChatGPT, + 124 tools any MCP client can call (156 if you're an admin). Claude, Claude Code, ChatGPT, Cursor, VS Code and Windsurf all have one-paste setup built into the app. - **Multi-user** — invite whoever you like. Each person gets a completely private workspace; admins manage accounts but never see anyone's career history, resumes or applications. Admin lives @@ -107,12 +107,14 @@ just *talk* to it. every migration finished, whether the last invite email actually left, and whether Stripe is still calling the webhook, then lists what has failed in the last thirty days. Ask an assistant for `admin_health` and you get the same answer without opening a browser. -- **Your inbox and calendar, on the record** — connect your own Gmail and Google Calendar - under Settings → Connections and every contact, company and application shows the real threads - and meetings behind it, under the timeline of what you logged. Interviews you accepted in - Google Calendar land on the pipeline's calendar view. Read-only and live: nothing from - your account is copied to the server, and an assistant asked where an application stands - reads the recruiter's reply instead of guessing from a stage. +- **Your inbox and calendar, on the record** — connect your own mail and calendar under + Settings → Connections — Google, Microsoft 365, or anything that speaks IMAP and CalDAV, + and more than one if recruiters write to more than one — and every contact, company and + application shows the real threads and meetings behind it, under the timeline of what you + logged. Interviews you accepted in your real calendar land on the pipeline's calendar view. + Read-only and live: nothing from any account is copied to the server, and an assistant + asked where an application stands reads the recruiter's reply instead of guessing from a + stage. - **Sign in how you like** — email and password always work, and an instance that adds a Google OAuth client gets a Continue with Google button as well. Google never bypasses an invitation: it signs in people who already have an account or an unexpired invite, and @@ -265,7 +267,7 @@ config already filled in with your URL, ready to copy. | **Anything else** | A standard `streamable-http` entry — or `mcp-remote` if it only speaks stdio | Hit **Test** next to any connection and the app calls its own endpoint the way a client -would, then tells you how many tools answered — 122, or 152 if you're an admin. +would, then tells you how many tools answered — 124, or 156 if you're an admin. #### One connection per client @@ -392,26 +394,38 @@ accounts here are matched by address, so that check is what the whole thing rest By conversation: `admin_get_google_config`, `admin_set_google_config`. -### Gmail and Calendar (optional, per person) - -The same OAuth client lets each person connect their own Gmail and Google Calendar under -**Settings → Connections**. Google asks for read-only access to both; either can be left -unticked. From then on a contact's page shows the threads with their address and the -meetings they are invited to, a company's page shows everything from its domain, an -application's page shows both under its timeline, and the pipeline's calendar view carries -the interviews from the real calendar. Nothing from anyone's account is copied to the -server: every page asks Google when it opens, and disconnecting revokes the token and -deletes the only thing held. - -Two things in the Cloud console make it work: enable the Gmail API and the Google Calendar -API in the project, and add the `gmail.readonly` and `calendar.readonly` scopes to the -consent screen. Gmail's read scope is one Google calls restricted, so leave the consent -screen in Testing and list the people who will connect as test users rather than going -through verification for an instance you host for friends. - -By conversation: `get_google_connection`, `list_correspondence`, `search_email`, -`get_email_thread`, `search_calendar`, `disconnect_google`, and the `inbox_review` workflow -that reads a week of mail and proposes what to log. +### Mail and calendar (optional, per person) + +Each person can connect the accounts recruiters actually write to, under **Settings → +Connections**, and the app reads them live: a contact's page shows the threads with their +address and the meetings they are invited to, a company's page shows everything from its +domain, an application's page shows both under its timeline, and the pipeline's calendar +view carries the interviews from the real calendar. More than one account merges. Nothing +from any account is copied to the server: every page asks the provider when it opens, and +disconnecting revokes what can be revoked and deletes the credential. + +Three kinds of account: + +- **Google** uses the sign-in client above. Two more things in the Cloud console make it + work: enable the Gmail API and the Google Calendar API, and add the `gmail.readonly` and + `calendar.readonly` scopes to the consent screen. Gmail's read scope is one Google calls + restricted, so leave the consent screen in Testing and list the people who will connect as + test users rather than going through verification for an instance you host for friends. +- **Microsoft 365 and Outlook.com** need an app registration in Microsoft Entra, set under + **Admin → Configuration → Accounts**: supported account types set to any directory plus + personal accounts, a Web redirect URI the screen shows you, the delegated Graph permissions + `Mail.Read`, `Calendars.Read`, `User.Read` and `offline_access`, and a client secret. +- **Anything else** — Fastmail, iCloud, Yahoo, a university account, a self-hosted server — + connects by IMAP and CalDAV with an app password, from a form with presets for the common + ones. It needs nothing from an admin. Either half can be left out. + +The app never sends, so there is no SMTP to configure: read-only is what makes handing over +an inbox safe, and the permissions it asks for cannot do anything else. + +By conversation: `list_linked_accounts`, `connect_imap_account`, `test_linked_account`, +`disconnect_account`, `list_correspondence`, `search_email`, `get_email_thread`, +`search_calendar`, and the `inbox_review` workflow that reads a week of mail and proposes +what to log. Admins: `admin_get_microsoft_config`, `admin_set_microsoft_config`. ### Everything else you can change @@ -443,13 +457,13 @@ By conversation: `admin_list_variables`, `admin_set_variable`, `admin_delete_var ## What your AI can do once it's connected -122 tools. One hundred and fourteen of them are the data tools across the four areas, the -archive that cuts through all of them, your Gmail and Calendar, and your account; the other -eight are the workflows below, published as tools as well as prompts, because prompt support -is optional in MCP clients and tool support isn't. Call one and it hands back a step-by-step -plan that it then follows. Admins get 30 more — 29 data tools and a ninth workflow — and -members never even see those in the tool list, so nobody is tempted by a permission they -don't have. +124 tools. One hundred and sixteen of them are the data tools across the four areas, the +archive that cuts through all of them, your mail and calendar accounts, and your own +account; the other eight are the workflows below, published as tools as well as prompts, +because prompt support is optional in MCP clients and tool support isn't. Call one and it +hands back a step-by-step plan that it then follows. Admins get 32 more — 31 data tools and +a ninth workflow — and members never even see those in the tool list, so nobody is tempted +by a permission they don't have. | Workflow | What it does | | --- | --- | @@ -460,7 +474,7 @@ don't have. | **Research a company into the CRM** | Gathers what's known, works out what's missing, and writes it back to their record without flattening what was already there. | | **Prepare for an interview** | Pulls the posting, the timeline, the company research, the people involved and your own evidence into one prep sheet. | | **Log what happened this week** | You ramble; it files everything to the right role, application, or note. | -| **Bring the pipeline up to date from your inbox** | Reads a week of your Gmail and Calendar, tells you what moved, and proposes what to log — nothing is written until you say yes. | +| **Bring the pipeline up to date from your inbox** | Reads a week of your mail and calendar, tells you what moved, and proposes what to log — nothing is written until you say yes. | | **Invite and onboard someone** *(admin)* | Invites a person, hands you the link if email isn't set up, and drafts the message to send them. | Every client is instructed never to invent experience, employers, dates, or metrics. If there's diff --git a/docs/app.mdx b/docs/app.mdx index 8d9f44c..0ef53e4 100644 --- a/docs/app.mdx +++ b/docs/app.mdx @@ -230,7 +230,8 @@ pasting a URL into an assistant, not changing a password. One row per thing wired to your workspace, each saying whether it is on and whether it needs you. Open one for its URL, setup steps, test, rename, rotate, disconnect — - or, for [Gmail and Calendar](/guides/inbox-and-calendar), to connect and revoke. + or, for a [mail and calendar account](/guides/inbox-and-calendar) — Google, Microsoft 365, + or IMAP and CalDAV — to test, reconnect and disconnect. **Connect** opens a library of everything you can add, assistants on one tab and accounts on the other, with what you already have marked as connected. Plus the three [skills](/skills), each as a raw `SKILL.md` and as a zip. diff --git a/docs/docs.json b/docs/docs.json index ce6036f..ac9d9df 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -138,7 +138,7 @@ "tools/pipeline", "tools/crm", "tools/archive", - "tools/google", + "tools/accounts", "tools/connections", "tools/admin" ] @@ -163,6 +163,7 @@ "self-hosting/configuration", "self-hosting/email", "self-hosting/google", + "self-hosting/microsoft", "self-hosting/pdf", "self-hosting/billing", "self-hosting/upgrading" diff --git a/docs/guides/inbox-and-calendar.mdx b/docs/guides/inbox-and-calendar.mdx index 03f84d5..2f7c3fa 100644 --- a/docs/guides/inbox-and-calendar.mdx +++ b/docs/guides/inbox-and-calendar.mdx @@ -11,21 +11,35 @@ calendar view shows the interview you accepted in Google Calendar, and an assist ## Connecting -**Settings → Connections → Google → Connect Google.** Google asks for read-only access to Gmail and -Calendar; you can leave either unticked and connect the other. The account you connect does -not have to be the one you sign in with. +**Settings → Connections → Connect an account.** Three kinds, and you can connect more than +one if recruiters write to more than one: + +- **Google.** A consent screen asks for read-only access to Gmail and Calendar; leave either + unticked and the other still connects. The account does not have to be the one you sign + in with. +- **Microsoft 365 or Outlook.com.** The same, through Microsoft's consent screen, for a work + account or a personal one. +- **Anything else** — Fastmail, iCloud, Yahoo, a university mailbox, a self-hosted server. + A short form takes the IMAP server and the CalDAV URL, with presets for the common + providers, and an **app password** from your provider's security settings. Never the + account password. Both servers are logged in to before anything is saved, so a wrong + password is an error now rather than a broken tile later. Either half can be left blank. - Nothing from your account is copied to the instance. Every page and every tool asks - Google at the moment you open it, and disconnecting deletes the only thing held — the - token. The app cannot send, accept, archive or delete anything; the permission it asks - for does not allow it. + Nothing from any account is copied to the instance. Every page and every tool asks the + provider at the moment you open it, and disconnecting deletes the only thing held — the + token or app password. The app cannot send, accept, archive or delete anything; the + permissions it asks for do not allow it, and there is no SMTP to configure because it never + sends. -If the button is missing, the instance has no Google OAuth client yet. That is an admin's -job, under Admin → Configuration → Sign-in, and it is the same client that powers -Continue with Google — see [Google sign-in](/self-hosting/google) for the two extra -switches Gmail and Calendar need. +If Google or Microsoft is missing from the picker, the instance has no client for it yet. +That is an admin's job: [Google sign-in](/self-hosting/google) for Google, which powers +Continue with Google too, and a [Microsoft app registration](/self-hosting/microsoft) for +Microsoft. IMAP and CalDAV need nothing from an admin. + +Open any account's tile to **Test** it — one read from each half, with the provider's own +words when something is wrong — rename it, reconnect it, or disconnect it. ## What shows where @@ -37,11 +51,13 @@ switches Gmail and Calendar need. happened. - **A resume** — the mail behind every application it went out with, in the evidence panel's "Sent to" tab. -- **The pipeline calendar** — meetings with anyone on the pipeline appear alongside your - follow-ups and tasks, and `list_schedule` returns them the same way. +- **The pipeline calendar** — meetings with anyone on the pipeline, from every connected + calendar, appear alongside your follow-ups and tasks, and `list_schedule` returns them the + same way. The same interview on a work and a personal calendar shows once. -Open a thread on any of those and it expands in place; **Open in Gmail** takes you to the -real thing. +Open a thread on any of those and it expands in place; **Open in Gmail** or **Open in +Outlook** takes you to the real thing where the provider has somewhere to go. An IMAP +account has no web client to link to, so its threads only expand. ## What to say @@ -50,6 +66,8 @@ real thing. - *"Did any rejections come in this week?"* — `search_email`. - *"What interviews do I have next week?"* — `search_calendar`, or `list_schedule` for the whole week including follow-ups. +- *"Connect my Fastmail — here's an app password."* — `connect_imap_account`, which logs in + before saving and never repeats the password. - *"Go through my inbox and bring the pipeline up to date."* — the `inbox_review` workflow: it reads every open application's mail, tells you what moved, and proposes the `log_activity` and `move_application_stage` calls for you to approve one by one. It diff --git a/docs/self-hosting/google.mdx b/docs/self-hosting/google.mdx index 5139018..3c2767b 100644 --- a/docs/self-hosting/google.mdx +++ b/docs/self-hosting/google.mdx @@ -34,7 +34,9 @@ changes either way, so it is a safe thing to try. The same OAuth client lets each person connect their own Gmail and Google Calendar under **Settings → Connections**, so the app can show the threads and meetings behind their contacts, -companies and applications. Two more things in the Cloud console make that work: +companies and applications. (Microsoft 365 has [its own page](/self-hosting/microsoft); IMAP +and CalDAV accounts need nothing from an admin.) Two more things in the Cloud console make +Google work: @@ -62,7 +64,7 @@ companies and applications. Two more things in the Cloud console make that work: Tokens are stored on your server as Google issues them, the same way MCP connection tokens are; the database is the trust boundary, and nothing read through a token is ever written -to it. Disconnecting — from Settings, or `disconnect_google` by conversation — revokes the +to it. Disconnecting — from Settings, or `disconnect_account` by conversation — revokes the token at Google and deletes the row. ## Who it lets in diff --git a/docs/self-hosting/microsoft.mdx b/docs/self-hosting/microsoft.mdx new file mode 100644 index 0000000..428a1bb --- /dev/null +++ b/docs/self-hosting/microsoft.mdx @@ -0,0 +1,51 @@ +--- +title: "Microsoft 365 accounts" +description: "The app registration that lets members connect their Outlook mail and calendar. Never used for sign-in." +icon: "microsoft" +--- + +Members can connect a Microsoft 365 or Outlook.com account under **Settings → +Connections**, and the app reads its mail and calendar live behind their contacts, companies +and applications. That needs an app registration in Microsoft Entra, which an admin sets up +once. It is separate from Google sign-in and is never used to sign anyone in. + +## Setting it up + + + + In the [Microsoft Entra admin center](https://entra.microsoft.com), **App registrations → + New registration**. For supported account types pick **Accounts in any organizational + directory and personal Microsoft accounts**, so a work mailbox and an Outlook.com mailbox + can both connect. + + + **Authentication → Add a platform → Web**, with the redirect URI that **Admin → + Configuration → Accounts** shows you, character for character. It is + `https://your-instance/api/auth/microsoft/callback`, built from **Public URL** — fix + that setting first if it looks wrong. A mismatch is the whole of `AADSTS50011`. + + + **API permissions → Add a permission → Microsoft Graph → Delegated**: `Mail.Read`, + `Calendars.Read`, `User.Read` and `offline_access`. None of these needs admin consent, + and none can send, move or delete anything. + + + **Certificates & secrets → New client secret.** Copy its **value** — it is shown once — + and paste it with the Application (client) ID into **Admin → Configuration → Accounts**. + Entra secrets expire, two years at most; note the date somewhere you will see it. + + + +The Microsoft option appears on the account picker the moment both are saved, and +disappears again if the client ID is cleared. Accounts already connected keep working until +their token next needs refreshing, then show as needing reconnecting. + +By conversation: `admin_get_microsoft_config`, `admin_set_microsoft_config`. + +## What is stored + +A refresh token per connected account, as Microsoft issues it, the same way MCP connection +tokens are stored. Microsoft rotates refresh tokens on use and the app keeps the newest. +Nothing read through a token is ever written to the database, and disconnecting deletes the +row. Microsoft has no revoke endpoint for a single app; a person who wants to be certain can +also remove the app under their Microsoft account's privacy settings. diff --git a/docs/tools/accounts.mdx b/docs/tools/accounts.mdx new file mode 100644 index 0000000..fb6d256 --- /dev/null +++ b/docs/tools/accounts.mdx @@ -0,0 +1,155 @@ +--- +title: "Mail and calendar tools" +sidebarTitle: "Mail & Calendar" +description: "Eight tools that read the threads and meetings in your own accounts — Google, Microsoft 365, or any IMAP and CalDAV provider — behind any contact, company, application or resume. Live, read-only, never copied." +icon: "envelope" +--- + +The pipeline's timeline only knows what was logged by hand. These tools know what actually +happened: the recruiter's reply, the interview on the calendar, the rejection that came in +on a Saturday. `list_correspondence` is the one to reach for first — pass a contact, company, +application or resume id and it works out what to match on, across every account you have +connected. + + + Everything here is **read-only and live**. The app asks the provider when a tool is called + and shows what came back; nothing from an inbox or a calendar is ever written to the + instance. None of these tools can send, accept, archive or delete anything, because the + permissions asked for do not allow it. + + +Three kinds of account, one shape of answer: + +| Provider | How it connects | Needs from an admin | +| --- | --- | --- | +| Google | A consent screen, from **Settings → Connections**. | The [Google client](/self-hosting/google), with the Gmail and Calendar APIs enabled. | +| Microsoft 365 / Outlook.com | A consent screen, from the same place. | A [Microsoft app registration](/self-hosting/microsoft). | +| Anything else — Fastmail, iCloud, Yahoo, self-hosted | IMAP and CalDAV with an app password, from the form or with `connect_imap_account`. | Nothing. | + +`list_linked_accounts` says what is connected and which halves — mail, calendar — each +provides. More than one account merges: a thread list is every account's threads sorted +together, each saying which `account` it came from, and one account failing is a line in +`warnings` rather than an empty result. + +How a record is matched: + +| Record | Matched on | +| --- | --- | +| Contact | Their email address. | +| Company | The domain of its website, plus everyone on file there. | +| Application | The company's domain, plus the people attached to that application. | +| Resume | Every application it is attached to. | + +A thin result usually means a contact with no email or a company with no website — the +`notes` field says which, and `update_contact` or `update_company` fixes it. + +### `list_linked_accounts` + +_Which mail and calendar accounts are connected_ + +Every mailbox and calendar this person has connected for the app to read — Google, Microsoft 365, or an IMAP and CalDAV provider such as Fastmail or iCloud — with which of mail and calendar each provides, its address, when it was last read, and whether it has broken and needs reconnecting (`lastError`). Call this first when a mail or calendar tool fails, or before promising to look something up in their inbox. Google and Microsoft connect through a consent screen in a browser, so they cannot be connected from here: when nothing is listed, tell them to open Settings → Connections in the app and add an account. An IMAP account can be connected with connect_imap_account. Nothing from any inbox is stored on this instance: every read is live, and disconnecting deletes the only thing held, the credential. + +No arguments. + +### `connect_imap_account` + +_Connect a mailbox by IMAP and a calendar by CalDAV_ + +Connect any mail provider that is not Google or Microsoft — Fastmail, iCloud, Yahoo, a university account, a self-hosted server — by its IMAP server and, optionally, its CalDAV URL. Either half may be left out. Both are logged in to before anything is saved, so a wrong password is an error now rather than a broken tile later. The password must be an APP PASSWORD generated in the provider's security settings, never the account password; say so before asking for one, and never repeat it back or write it anywhere. Connecting an address that is already connected replaces its stored details. Presets worth knowing: Fastmail is imap.fastmail.com with CalDAV at https://caldav.fastmail.com/; iCloud is imap.mail.me.com with CalDAV at https://caldav.icloud.com/; Yahoo is imap.mail.yahoo.com. Read-only: the app can never send, move or delete anything with what it stores. + +**Reaches outside this instance.** + +| Argument | Type | | +| --- | --- | --- | +| `email` | string | **required** — The address of the mailbox. | +| `label` | string | What to call it on the tile, e.g. 'Work' or 'Old university address'. Optional. | +| `imapHost` | string | IMAP server, e.g. imap.fastmail.com. Leave out for a calendar-only account. | +| `imapPort` | number | IMAP port. Default 993 (TLS). | +| `imapUsername` | string | IMAP username. Defaults to the address. | +| `imapPassword` | string | An app password for IMAP. Never the account password. | +| `caldavUrl` | string | CalDAV server or calendar-home URL, e.g. https://caldav.fastmail.com/. Leave out for a mail-only account. | +| `caldavUsername` | string | CalDAV username. Defaults to the IMAP username, then the address. | +| `caldavPassword` | string | An app password for CalDAV. Defaults to the IMAP password — many providers use one for both. | + +### `test_linked_account` + +_Test a connected account_ + +Read one thing from each half of a connected account — the most recent mail, the events around today — and report whether it answered, with the provider's own words when it did not. The way to find out whether 'no threads' means an empty result or a dead connection. Get the id from list_linked_accounts. Records the outcome on the account (`lastError`), which is why it is not marked read-only. + +**Reaches outside this instance.** + +| Argument | Type | | +| --- | --- | --- | +| `accountId` | string | **required** — The account id from list_linked_accounts. | + +### `disconnect_account` + +_Disconnect a mail or calendar account_ + +Forget one connected account: revoke this instance's access where the provider allows it (Google) and delete the credential. Every read from that account stops immediately; nothing else — no contact, application or logged activity — is touched, because nothing from it was ever stored. Other accounts stay connected. Confirm before calling it. Get the id from list_linked_accounts; reconnecting is the same consent screen or form as the first time, under Settings → Connections. + +**Overwrites or deletes.** **Reaches outside this instance.** + +| Argument | Type | | +| --- | --- | --- | +| `accountId` | string | **required** — The account id from list_linked_accounts. | + +### `list_correspondence` + +_Mail and meetings about one record_ + +Every email thread and calendar event, across all of the person's connected accounts, that involves one thing on the pipeline: a contact (matched on their email address), a company (its website's domain plus everyone on file there), an application (its company's domain plus the people attached to it) or a resume (every application it was sent with). This is the tool for 'what's the latest with Stripe', 'have I heard back from Jane', 'when is my interview' and 'what did the recruiter actually say' — call it before summarising where an application stands, because the pipeline's timeline only knows what was logged by hand. Pass exactly one id. Returns `mail` (threads, newest first, with subject, snippet, participants, which `account` it came from and a link where the provider has one) and `calendar` (past and upcoming events, with attendees, a meeting link and a link) — either is null when no account provides that half or every account refused, with the reason in `warnings`. `notes` explains a thin result, usually a contact with no email or a company with no website; fix those with update_contact and update_company and call again. Nothing is saved. To read a thread in full, pass its id to get_email_thread; to remember what you learned, log_activity on the application or contact. + +**Reaches outside this instance.** + +| Argument | Type | | +| --- | --- | --- | +| `contactId` | string | A contact id. Matches their email address. | +| `companyId` | string | A company id. Matches its website's domain and the addresses of its people. | +| `applicationId` | string | An application id. Matches the company's domain and the people attached to this application. | +| `resumeId` | string | A resume id. Matches every application the resume is attached to. | +| `limit` | number | How many threads to return at most. Default 20, maximum 50. | +| `days` | number | How far back to look, in days. Default 365. Calendar events up to 120 days ahead are always included. | + +### `search_email` + +_Search mail_ + +Search every connected mailbox, or one of them, for free text — 'take-home', 'phone screen', a recruiter's name. On a Gmail account, Gmail's own operators work too: `from:jane@acme.com`, `subject:offer newer_than:7d`, `has:attachment`. Reach for this when the question is about mail that does not map to one record: 'did any rejections come in this week', 'find the email with the take-home', 'who have I emailed about referrals'. For mail about a specific contact, company or application, list_correspondence already builds the right query. Returns threads newest first, merged across accounts, each with its subject, a snippet of the latest message (empty on IMAP), everyone on the thread, when it last moved, which `account` it is in, and a link that opens it in the provider's client where there is one. Subjects and snippets only — pass a thread id to get_email_thread for the messages themselves. `warnings` names any account that did not answer. Read-only; nothing is saved, and this tool cannot send, archive or delete anything. + +**Reaches outside this instance.** + +| Argument | Type | | +| --- | --- | --- | +| `query` | string | **required** — Words to search for. Gmail operators pass through on a Gmail account. | +| `limit` | number | How many threads at most. Default 20, maximum 50. | +| `accountId` | string | Search one account only. Omit for all of them. | + +### `get_email_thread` + +_Read an email thread_ + +One thread in full, oldest message first: who sent each message, to whom, when, and the body as plain text (HTML mail is stripped to text; attachments are never fetched; very long messages are cut). The id comes from list_correspondence or search_email and already says which account it lives in. This is how you find out what a recruiter actually wrote — the dates they proposed, the salary they named, the next step they described — before logging it with log_activity or moving the application with move_application_stage. Quote the mail when you report it; do not paraphrase a number. Read-only, and nothing about the thread changes: it is not marked read. + +**Reaches outside this instance.** + +| Argument | Type | | +| --- | --- | --- | +| `threadId` | string | **required** — The thread id from list_correspondence or search_email. | + +### `search_calendar` + +_Search calendars_ + +Events across every connected calendar, or one of them, in a window, optionally filtered by words matched against title, description, location and attendee names and addresses. Use it for 'what interviews do I have this week', 'when did I last meet anyone from Acme' or 'am I free Thursday afternoon' — for a whole week of the pipeline's own dates alongside these meetings, list_schedule merges both. Defaults to thirty days back and sixty ahead. Each event has its title, start and end, whether it is all-day, the attendees with their RSVP, the organizer, a meeting link when there is one, which `account` it is on, and a link to the event where the provider has one. `warnings` names any account that did not answer. Read-only; nothing here creates, accepts or declines anything. + +**Reaches outside this instance.** + +| Argument | Type | | +| --- | --- | --- | +| `query` | string | Words to match against title, description, location and attendees. Omit for every event in the window. | +| `from` | string | Start of the window, ISO date (YYYY-MM-DD). Default: 30 days ago. | +| `to` | string | End of the window, ISO date (YYYY-MM-DD), inclusive. Default: 60 days ahead. | +| `limit` | number | How many events at most. Default 100. | +| `accountId` | string | Search one account only. Omit for all of them. | diff --git a/docs/tools/admin.mdx b/docs/tools/admin.mdx index 9935a83..9fecfcd 100644 --- a/docs/tools/admin.mdx +++ b/docs/tools/admin.mdx @@ -1,7 +1,7 @@ --- title: "Admin tools" sidebarTitle: "Admin" -description: "Twenty-nine tools for running an instance: accounts, invitations, the waitlist, sign-in, email, billing, health and every stored setting." +description: "Thirty-one tools for running an instance: accounts, invitations, the waitlist, sign-in, email, billing, health and every stored setting." icon: "shield-halved" --- @@ -279,6 +279,29 @@ Proves the Resend configuration actually delivers, and doubles as the way to loo | `to` | string | Where to send it. Defaults to your own address. | | `template` | string | Which email to send: test, invite or waitlist. Defaults to test. | +### `admin_get_microsoft_config` + +_Check the Microsoft 365 app registration_ + +Whether members can connect their Microsoft 365 or Outlook.com mail and calendar, and the exact redirect URI to register on the app registration in Microsoft Entra — the value behind AADSTS50011 when it does not match. The client secret comes back masked. This is separate from Google sign-in and from the Google client: it is never used to sign in, only for members who choose to connect an Outlook mailbox under Settings → Connections. + +**Admin only.** + +No arguments. + +### `admin_set_microsoft_config` + +_Configure the Microsoft 365 app registration_ + +Set or clear the Microsoft Entra app registration members use to connect Outlook mail and calendar. Pass the Application (client) ID and a client secret VALUE. Clearing the client id takes the Microsoft option off the account picker; accounts already connected keep working until their token needs refreshing, then show as needing reconnecting. Recorded in the admin audit log; the secret is recorded as having changed, never as its value. + +**Admin only.** **Overwrites or deletes.** + +| Argument | Type | | +| --- | --- | --- | +| `clientId` | string | The Application (client) ID. Empty string to turn the option off. | +| `clientSecret` | string | A client secret value from Certificates & secrets. Omit to keep the current one. | + ### `admin_get_google_config` _Check Google sign-in_ diff --git a/docs/tools/google.mdx b/docs/tools/google.mdx deleted file mode 100644 index 723efd2..0000000 --- a/docs/tools/google.mdx +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: "Gmail and Calendar tools" -sidebarTitle: "Gmail & Calendar" -description: "Six tools that read the threads and meetings in your own Google account behind any contact, company, application or resume — live, read-only, never copied." -icon: "envelope" ---- - -The pipeline's timeline only knows what was logged by hand. These tools know what actually -happened: the recruiter's reply, the interview on the calendar, the rejection that came in -on a Saturday. `list_correspondence` is the one to reach for first — pass a contact, company, -application or resume id and it works out what to match on. - - - Everything here is **read-only and live**. The app asks Google when a tool is called and - shows what came back; nothing from an inbox or a calendar is ever written to the instance. - None of these tools can send, accept, archive or delete anything, because the permission - Google was asked for does not allow it. - - -Connecting is a consent screen at Google, so it cannot be done by tool: it is **Settings → -Google** in the app. `get_google_connection` says whether that has happened and which of the -two — Gmail, Calendar — was granted. An instance needs a Google OAuth client for any of this -to exist; see [Google sign-in](/self-hosting/google), which the same client provides. - -How a record is matched: - -| Record | Matched on | -| --- | --- | -| Contact | Their email address. | -| Company | The domain of its website, plus everyone on file there. | -| Application | The company's domain, plus the people attached to that application. | -| Resume | Every application it is attached to. | - -A thin result usually means a contact with no email or a company with no website — the -`notes` field says which, and `update_contact` or `update_company` fixes it. - -### `get_google_connection` - -_Is Gmail and Calendar connected_ - -Whether this person has connected their Gmail and Google Calendar, which of the two was granted, which Google address it is, and whether the connection has broken and needs reconnecting. Call this first when a mail or calendar tool fails, or before promising to look something up in their inbox. Connecting cannot be done from here — it is a consent screen at Google — so when `connected` is false, tell them to open Settings → Connections in the app, open the Google tile and press Connect, then come back. Nothing in the inbox is stored on this instance: every read is live, and disconnecting deletes the only thing held, the token. - -No arguments. - -### `list_correspondence` - -_Mail and meetings about one record_ - -Every email thread and calendar event in the person's own Google account that involves one thing on the pipeline: a contact (matched on their email address), a company (its website's domain plus everyone on file there), an application (its company's domain plus the people attached to it) or a resume (every application it was sent with). This is the tool for 'what's the latest with Stripe', 'have I heard back from Jane', 'when is my interview' and 'what did the recruiter actually say' — call it before summarising where an application stands, because the pipeline's timeline only knows what was logged by hand. Pass exactly one id. Returns `mail` (threads, newest first, with subject, snippet, participants and a link) and `calendar` (past and upcoming events, with attendees, a Meet link and a link) — either is null when that half is not granted or Google refused, with the reason in `warnings`. `notes` explains a thin result, usually a contact with no email or a company with no website; fix those with update_contact and update_company and call again. Nothing is saved. To read a thread in full, pass its id to get_email_thread; to remember what you learned, log_activity on the application or contact. - -**Reaches outside this instance.** - -| Argument | Type | | -| --- | --- | --- | -| `contactId` | string | A contact id. Matches their email address. | -| `companyId` | string | A company id. Matches its website's domain and the addresses of its people. | -| `applicationId` | string | An application id. Matches the company's domain and the people attached to this application. | -| `resumeId` | string | A resume id. Matches every application the resume is attached to. | -| `limit` | number | How many threads to return at most. Default 20, maximum 50. | -| `days` | number | How far back to look, in days. Default 365. Calendar events up to 120 days ahead are always included. | - -### `search_email` - -_Search Gmail_ - -Search the person's Gmail with Gmail's own query syntax — `from:jane@acme.com`, `subject:offer newer_than:7d`, `"phone screen"` — or plain words. Reach for this when the question is about mail that does not map to one record: 'did any rejections come in this week', 'find the email with the take-home', 'who have I emailed about referrals'. For mail about a specific contact, company or application, list_correspondence already builds the right query. Returns threads newest first with subject, Gmail's snippet of the latest message, everyone on the thread, when it last moved and a link that opens it in Gmail. Subjects and snippets only — pass a thread id to get_email_thread for the messages themselves. Read-only; nothing is saved, and this tool cannot send, archive or delete anything. - -**Reaches outside this instance.** - -| Argument | Type | | -| --- | --- | --- | -| `query` | string | **required** — A Gmail search. Operators like from:, to:, subject:, newer_than:7d, has:attachment and label: all work, as do plain words. | -| `limit` | number | How many threads at most. Default 20, maximum 50. | - -### `get_email_thread` - -_Read an email thread_ - -One thread in full, oldest message first: who sent each message, to whom, when, and the body as plain text (HTML mail is stripped to text; attachments are never fetched; very long messages are cut). The id comes from list_correspondence or search_email. This is how you find out what a recruiter actually wrote — the dates they proposed, the salary they named, the next step they described — before logging it with log_activity or moving the application with move_application_stage. Quote the mail when you report it; do not paraphrase a number. Read-only, and nothing about the thread changes: it is not marked read. - -**Reaches outside this instance.** - -| Argument | Type | | -| --- | --- | --- | -| `threadId` | string | **required** — The thread id from list_correspondence or search_email. | - -### `search_calendar` - -_Search Google Calendar_ - -Events on the person's primary Google Calendar in a window, optionally filtered by a free-text search over title, description, location and attendee addresses. Use it for 'what interviews do I have this week', 'when did I last meet anyone from Acme' or 'am I free Thursday afternoon' — for a whole week of the pipeline's own dates alongside these meetings, list_schedule merges both. Defaults to thirty days back and sixty ahead. Each event has its title, start and end, whether it is all-day, the attendees with their RSVP, the organizer, a Meet link when there is one, and a link to the event. Read-only; nothing here creates, accepts or declines anything. - -**Reaches outside this instance.** - -| Argument | Type | | -| --- | --- | --- | -| `query` | string | Words to match against title, description, location and attendee emails. Omit for every event in the window. | -| `from` | string | Start of the window, ISO date (YYYY-MM-DD). Default: 30 days ago. | -| `to` | string | End of the window, ISO date (YYYY-MM-DD), inclusive. Default: 60 days ahead. | -| `limit` | number | How many events at most. Default 100. | - -### `disconnect_google` - -_Disconnect Gmail and Calendar_ - -Revoke this instance's access to the person's Gmail and Google Calendar and forget the token. Every mail and calendar tool stops working immediately and the panels in the app go back to offering a Connect button; nothing else — no contact, application or logged activity — is touched, because nothing from Google was ever stored. Confirm before calling it. Reconnecting is the same consent screen as the first time, under Settings → Connections. - -**Overwrites or deletes.** **Reaches outside this instance.** - -No arguments. diff --git a/docs/tools/overview.mdx b/docs/tools/overview.mdx index 04f140f..ab959f1 100644 --- a/docs/tools/overview.mdx +++ b/docs/tools/overview.mdx @@ -13,9 +13,9 @@ This section is the full catalogue, generated from the same definitions the serv {/* generated:counts */} | | Member | Admin | | --- | --- | --- | -| Data tools | 114 | 143 | +| Data tools | 116 | 147 | | Workflows, also published as tools | 8 | 9 | -| **What `tools/list` returns** | **122** | **152** | +| **What `tools/list` returns** | **124** | **156** | {/* /generated:counts */} Admin tools are **hidden** from a member's `tools/list` entirely, not merely refused — so @@ -42,18 +42,18 @@ connection is real. 5 tools · what has been deleted, putting it back, and getting rid of it for good. - - 6 tools · the threads and meetings behind any record, read live from your own Google account. + + 8 tools · the threads and meetings behind any record, read live from your own Google, Microsoft 365 or IMAP and CalDAV accounts. 7 tools · who you are, and the wiring itself. - 29 tools · accounts, invitations, the waitlist, sign-in, email, billing, health, configuration. + 31 tools · accounts, invitations, the waitlist, sign-in, email, billing, health, configuration. -That is 143. The remaining 9 are the [workflows](/workflows), which are +That is 147. The remaining 9 are the [workflows](/workflows), which are published as tools as well as prompts — so they appear in `tools/list` alongside everything above, and are documented on their own page rather than here. {/* /generated:areas */} diff --git a/docs/workflows.mdx b/docs/workflows.mdx index 632ce42..34fad17 100644 --- a/docs/workflows.mdx +++ b/docs/workflows.mdx @@ -187,8 +187,8 @@ Reads a week of your own Gmail and Google Calendar — every open application, e contact with a ping due — and tells you what moved that the pipeline does not know yet: a reply, an interview on the calendar, a rejection, an offer. Then it proposes the `log_activity` and `move_application_stage` calls, one line each, and waits for a yes on -each before writing anything. Needs Gmail and Calendar connected under Settings → Connections; -if they are not, it says how rather than guessing at your mail. +each before writing anything. Needs a mail or calendar account connected under Settings → +Connections; if none is, it says how rather than guessing at your mail. --- diff --git a/next.config.ts b/next.config.ts index 00817ec..3a5a5a6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,7 +5,10 @@ import { MANUAL_URL } from "./src/lib/links"; const nextConfig: NextConfig = { eslint: { ignoreDuringBuilds: true }, typescript: { ignoreBuildErrors: false }, - serverExternalPackages: ["@prisma/client", "@modelcontextprotocol/sdk"], + // The mail and calendar libraries are Node-only (sockets, TLS) and are + // reached only from the server side of the accounts layer; leaving them + // out of the bundle keeps their dynamic requires intact. + serverExternalPackages: ["@prisma/client", "@modelcontextprotocol/sdk", "imapflow", "mailparser", "tsdav", "ical.js"], experimental: { serverActions: { bodySizeLimit: "4mb" }, }, diff --git a/package-lock.json b/package-lock.json index 665b9b8..b12719c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,10 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.15.0", + "ical.js": "^2.2.1", + "imapflow": "^1.7.8", "lucide-react": "^0.511.0", + "mailparser": "^3.9.20", "next": "^15.3.2", "next-themes": "^0.4.6", "playwright-core": "^1.62.1", @@ -49,10 +52,14 @@ "sonner": "^2.0.3", "tailwind-merge": "^3.3.0", "tailwindcss": "^4.1.8", + "tsdav": "^2.3.1", "tw-animate-css": "^1.3.0", "typescript": "^5.8.3", "zod": "^3.25.28" }, + "devDependencies": { + "@types/mailparser": "^3.4.6" + }, "engines": { "node": ">=20" } @@ -449,6 +456,12 @@ "node": ">= 10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@prisma/client": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", @@ -1445,6 +1458,22 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1725,6 +1754,17 @@ "tailwindcss": "4.3.3" } }, + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -1752,6 +1792,17 @@ "@types/react": "^19.2.0" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.16", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz", + "integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.3", + "libqp": "2.1.1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -1764,6 +1815,15 @@ "node": ">=10" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -1916,6 +1976,23 @@ "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -1952,6 +2029,61 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1983,6 +2115,15 @@ "node": ">=14" } }, + "node_modules/encoding-japanese": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz", + "integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -1996,6 +2137,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/exsolve": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", @@ -2083,6 +2236,144 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.1.tgz", + "integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^8.0.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/html-to-text/node_modules/deepmerge-ts": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", + "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/ical.js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz", + "integrity": "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==", + "license": "MPL-2.0" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/imapflow": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz", + "integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.16", + "encoding-japanese": "2.3.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libmime": "5.4.3", + "libqp": "2.1.1", + "pino": "10.3.1", + "socks": "2.8.9" + } + }, + "node_modules/imapflow/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2092,6 +2383,55 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz", + "integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.3.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2341,6 +2681,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lucide-react": { "version": "0.511.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", @@ -2359,6 +2718,43 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mailparser": { + "version": "3.9.23", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.23.tgz", + "integrity": "sha512-5jpsKltHt9oudhMu6mXxiwWMWV/CILfly+m9xhKiKW6cdmfOVvcAZ/NCuChV6QlADJqx1e5E2hwDAkP7lTP+Lg==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.16", + "encoding-japanese": "2.3.0", + "he": "1.2.0", + "html-to-text": "10.0.1", + "iconv-lite": "0.7.3", + "libmime": "5.4.3", + "linkify-it": "5.0.2", + "nodemailer": "10.0.1", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/mailparser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/motion-dom": { "version": "12.43.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", @@ -2374,6 +2770,12 @@ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.17", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", @@ -2488,6 +2890,15 @@ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.1.tgz", + "integrity": "sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/nypm": { "version": "0.6.9", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz", @@ -2517,12 +2928,43 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -2535,6 +2977,43 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkg-types": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", @@ -2611,6 +3090,31 @@ } } }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -2627,6 +3131,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -2762,12 +3272,57 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2826,6 +3381,39 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -2845,6 +3433,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -2897,6 +3494,24 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", @@ -2906,6 +3521,28 @@ "node": ">=18" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/tsdav": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tsdav/-/tsdav-2.3.3.tgz", + "integrity": "sha512-MCsxZDYNy/ueyGzM5mrxZoYzToCYbCil0HDd7spGEMd/71cwviwJ6Ob/VbGVklzSg8Q1Tg//xAdyxVcVrzuEyQ==", + "license": "MIT", + "dependencies": { + "debug": "4.4.3", + "xml-js": "1.6.11" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2934,6 +3571,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -2983,6 +3626,18 @@ } } }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 017332a..441a8ad 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,10 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.15.0", + "ical.js": "^2.2.1", + "imapflow": "^1.7.8", "lucide-react": "^0.511.0", + "mailparser": "^3.9.20", "next": "^15.3.2", "next-themes": "^0.4.6", "playwright-core": "^1.62.1", @@ -54,10 +57,14 @@ "sonner": "^2.0.3", "tailwind-merge": "^3.3.0", "tailwindcss": "^4.1.8", + "tsdav": "^2.3.1", "tw-animate-css": "^1.3.0", "typescript": "^5.8.3", "zod": "^3.25.28" }, "license": "AGPL-3.0-only", - "description": "Your career on the record, resume builder and job-search CRM — self-hosted and wired to Claude over MCP." + "description": "Your career on the record, resume builder and job-search CRM — self-hosted and wired to Claude over MCP.", + "devDependencies": { + "@types/mailparser": "^3.4.6" + } } diff --git a/prisma/migrations/20250126000000_linked_accounts/migration.sql b/prisma/migrations/20250126000000_linked_accounts/migration.sql new file mode 100644 index 0000000..96b8921 --- /dev/null +++ b/prisma/migrations/20250126000000_linked_accounts/migration.sql @@ -0,0 +1,39 @@ +-- GoogleAccount becomes LinkedAccount: one table for every mailbox and +-- calendar the app reads on a person's behalf — Google, Microsoft 365, or +-- anything that speaks IMAP and CalDAV — and more than one per person, because +-- a work Outlook and a personal Gmail are both where recruiters write. +-- +-- A rename rather than a new table so a Google connection made on the +-- previous release survives: its refresh token, its address and which of +-- Gmail and Calendar it granted (rewritten from Google's scope URLs into the +-- provider-neutral feature names the app uses now). +CREATE TYPE "AccountProvider" AS ENUM ('GOOGLE', 'MICROSOFT', 'IMAP'); + +ALTER TABLE "GoogleAccount" RENAME TO "LinkedAccount"; +ALTER TABLE "LinkedAccount" RENAME CONSTRAINT "GoogleAccount_pkey" TO "LinkedAccount_pkey"; +ALTER TABLE "LinkedAccount" RENAME CONSTRAINT "GoogleAccount_userId_fkey" TO "LinkedAccount_userId_fkey"; +DROP INDEX "GoogleAccount_userId_key"; + +ALTER TABLE "LinkedAccount" ADD COLUMN "provider" "AccountProvider" NOT NULL DEFAULT 'GOOGLE'; +ALTER TABLE "LinkedAccount" ALTER COLUMN "provider" DROP DEFAULT; +ALTER TABLE "LinkedAccount" RENAME COLUMN "googleId" TO "externalId"; +ALTER TABLE "LinkedAccount" ALTER COLUMN "refreshToken" SET DEFAULT ''; + +ALTER TABLE "LinkedAccount" ADD COLUMN "label" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "LinkedAccount" ADD COLUMN "features" TEXT[] DEFAULT ARRAY[]::TEXT[]; +UPDATE "LinkedAccount" SET "features" = ARRAY_REMOVE(ARRAY[ + CASE WHEN 'https://www.googleapis.com/auth/gmail.readonly' = ANY("scopes") THEN 'mail' END, + CASE WHEN 'https://www.googleapis.com/auth/calendar.readonly' = ANY("scopes") THEN 'calendar' END +], NULL); +ALTER TABLE "LinkedAccount" DROP COLUMN "scopes"; + +ALTER TABLE "LinkedAccount" ADD COLUMN "imapHost" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "LinkedAccount" ADD COLUMN "imapPort" INTEGER NOT NULL DEFAULT 993; +ALTER TABLE "LinkedAccount" ADD COLUMN "imapUsername" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "LinkedAccount" ADD COLUMN "imapPassword" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "LinkedAccount" ADD COLUMN "caldavUrl" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "LinkedAccount" ADD COLUMN "caldavUsername" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "LinkedAccount" ADD COLUMN "caldavPassword" TEXT NOT NULL DEFAULT ''; + +CREATE UNIQUE INDEX "LinkedAccount_userId_provider_email_key" ON "LinkedAccount"("userId", "provider", "email"); +CREATE INDEX "LinkedAccount_userId_idx" ON "LinkedAccount"("userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0ffca8..9db2c0b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,45 +79,68 @@ model User { savedViews SavedView[] tags Tag[] pipelineShare PipelineShare? - googleAccount GoogleAccount? + linkedAccounts LinkedAccount[] @@index([role]) @@index([stripeCustomerId]) } -/// A person's own Gmail and Google Calendar, read on their behalf. +/// A mailbox and calendar the app reads on a person's behalf: their Gmail, +/// their Microsoft 365, or any provider that speaks IMAP and CalDAV. /// -/// Separate from User.googleId on purpose: signing in with Google proves who -/// you are, and this is permission to read your inbox — two different grants -/// that a person may give to two different Google accounts. One row per user; -/// connecting again replaces it. +/// Separate from User.googleId on purpose: signing in proves who you are, +/// and this is permission to read your inbox — two different grants that a +/// person may give to two different accounts. A person may have several +/// (a work Outlook and a personal Gmail); every read merges across them. /// -/// The refresh token is stored as Google issues it, the way McpConnection -/// stores its token: the database is already the trust boundary here, and an -/// instance has no secret of its own to wrap it in without a second required -/// environment variable. Nothing read through it is ever copied into this -/// database — every screen and tool asks Google live and shows what came back. -model GoogleAccount { - id String @id @default(cuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - /// The Google address the grant is for, shown so a person can tell which - /// inbox they connected. +/// Credentials are stored as issued — a refresh token for the OAuth +/// providers, an app password for IMAP — the way McpConnection stores its +/// token: the database is already the trust boundary here, and an instance +/// has no secret of its own to wrap them in without a second required +/// environment variable. Nothing read through them is ever copied into this +/// database; every screen and tool asks the provider live. +model LinkedAccount { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + provider AccountProvider + /// The address of the mailbox, shown so a person can tell which one this is. email String - googleId String @default("") - /// What Google actually granted. The consent screen lets people untick a - /// scope, so the app reads this rather than assuming it got what it asked. - scopes String[] @default([]) - refreshToken String - accessToken String @default("") + /// An optional name for the tile: "Work", "Old university address". + label String @default("") + /// What this account provides: "mail", "calendar". The OAuth consent + /// screens let people untick one, and an IMAP account may have no CalDAV. + features String[] @default([]) + /// The provider's own id for the account, when it has one. + externalId String @default("") + refreshToken String @default("") + accessToken String @default("") accessTokenExpiresAt DateTime? - /// Set when Google refused a refresh — the person revoked access from their - /// Google account, or the instance's client changed. Cleared on reconnect. - lastError String @default("") + /// IMAP and CalDAV, for the provider-less kind. App passwords, not the + /// account password — every provider that supports this issues them. + imapHost String @default("") + imapPort Int @default(993) + imapUsername String @default("") + imapPassword String @default("") + caldavUrl String @default("") + caldavUsername String @default("") + caldavPassword String @default("") + /// Set when a read failed in a way that needs a person — a revoked token, + /// a changed password. Cleared on reconnect. + lastError String @default("") lastErrorAt DateTime? lastUsedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, provider, email]) + @@index([userId]) +} + +enum AccountProvider { + GOOGLE + MICROSOFT + IMAP } model Session { diff --git a/src/app/(app)/applications/[id]/page.tsx b/src/app/(app)/applications/[id]/page.tsx index 2032e1c..e6e64b7 100644 --- a/src/app/(app)/applications/[id]/page.tsx +++ b/src/app/(app)/applications/[id]/page.tsx @@ -9,7 +9,7 @@ import { listTags } from "@/lib/data/tags"; import { getResume, listResumeNames } from "@/lib/data/resumes"; import { requireUser } from "@/lib/auth"; import { getSettings } from "@/lib/settings"; -import { getGoogleConnection } from "@/lib/data/google"; +import { accountAccess } from "@/lib/data/accounts"; import { ApplicationDetail } from "@/components/pipeline/application-detail"; export const dynamic = "force-dynamic"; @@ -24,7 +24,7 @@ export default async function ApplicationPage({ params }: { params: Promise<{ id listTags(user.id, "APPLICATION"), listCompanies(user.id), getSettings(), - getGoogleConnection(user.id), + accountAccess(user.id), applicationFieldValues(user.id), ]); if (!application) notFound(); @@ -117,9 +117,7 @@ export default async function ApplicationPage({ params }: { params: Promise<{ id : null } logos={companyLogos} - googleAccess={ - googleConnection ? { mail: googleConnection.mail, calendar: googleConnection.calendar } : null - } + googleAccess={googleConnection} /> diff --git a/src/app/(app)/applications/page.tsx b/src/app/(app)/applications/page.tsx index 89bf04a..eceb27f 100644 --- a/src/app/(app)/applications/page.tsx +++ b/src/app/(app)/applications/page.tsx @@ -8,8 +8,8 @@ import { STAGE_LABEL, TERMINAL_STAGES, listApplications, - listSchedule, } from "@/lib/data/pipeline"; +import { listSchedule } from "@/lib/data/schedule"; import { listResumeNames } from "@/lib/data/resumes"; import { listTags } from "@/lib/data/tags"; import { archiveCounts } from "@/lib/data/archive"; @@ -56,7 +56,7 @@ export const dynamic = "force-dynamic"; const BLURB: Record = { board: "Drag a card to move it forward. Follow-up dates set themselves when the stage changes.", list: "Every application in one table. Click a column to sort by it, again to reverse.", - calendar: "Follow-ups, task deadlines, everything you have logged, and — with Google connected — the interviews on your own calendar, by the day it lands.", + calendar: "Follow-ups, task deadlines, everything you have logged, and — with a calendar connected — the interviews on your own calendar, by the day it lands.", }; function filterLabel(filters: PipelineFilters) { diff --git a/src/app/(app)/crm/companies/[id]/page.tsx b/src/app/(app)/crm/companies/[id]/page.tsx index 9b98597..57efd45 100644 --- a/src/app/(app)/crm/companies/[id]/page.tsx +++ b/src/app/(app)/crm/companies/[id]/page.tsx @@ -10,7 +10,7 @@ import { tagsOfKind } from "@/lib/data/tags"; import { companyKey } from "@/lib/company"; import { requireUser } from "@/lib/auth"; import { getSettings } from "@/lib/settings"; -import { getGoogleConnection } from "@/lib/data/google"; +import { accountAccess } from "@/lib/data/accounts"; export const dynamic = "force-dynamic"; @@ -21,7 +21,7 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st getCompany(user.id, id), getSettings(), listCompanies(user.id), - getGoogleConnection(user.id), + accountAccess(user.id), ]); if (!company) notFound(); @@ -86,9 +86,7 @@ export default async function CompanyPage({ params }: { params: Promise<{ id: st logos={companyLogos} candidates={candidates} suggestedMergeId={suggestedMergeId} - googleAccess={ - googleConnection ? { mail: googleConnection.mail, calendar: googleConnection.calendar } : null - } + googleAccess={googleConnection} /> diff --git a/src/app/(app)/crm/contacts/[id]/page.tsx b/src/app/(app)/crm/contacts/[id]/page.tsx index 518cecb..885f27b 100644 --- a/src/app/(app)/crm/contacts/[id]/page.tsx +++ b/src/app/(app)/crm/contacts/[id]/page.tsx @@ -8,7 +8,7 @@ import { ContactDetail } from "@/components/crm/contact-detail"; import { getContact, listCompanies } from "@/lib/data/pipeline"; import { requireUser } from "@/lib/auth"; import { getSettings } from "@/lib/settings"; -import { getGoogleConnection } from "@/lib/data/google"; +import { accountAccess } from "@/lib/data/accounts"; export const dynamic = "force-dynamic"; @@ -19,7 +19,7 @@ export default async function ContactPage({ params }: { params: Promise<{ id: st getContact(user.id, id), listCompanies(user.id), getSettings(), - getGoogleConnection(user.id), + accountAccess(user.id), ]); if (!contact) notFound(); @@ -83,9 +83,7 @@ export default async function ContactPage({ params }: { params: Promise<{ id: st : null } logos={companyLogos} - googleAccess={ - googleConnection ? { mail: googleConnection.mail, calendar: googleConnection.calendar } : null - } + googleAccess={googleConnection} /> diff --git a/src/app/(app)/resumes/[id]/page.tsx b/src/app/(app)/resumes/[id]/page.tsx index a110d5a..de54bc0 100644 --- a/src/app/(app)/resumes/[id]/page.tsx +++ b/src/app/(app)/resumes/[id]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import { getResume, listResumeNames } from "@/lib/data/resumes"; import { getProfile } from "@/lib/data/me"; import { requireUser } from "@/lib/auth"; -import { getGoogleConnection } from "@/lib/data/google"; +import { accountAccess } from "@/lib/data/accounts"; import { ResumeEditor } from "@/components/resume/resume-editor"; export const dynamic = "force-dynamic"; @@ -21,7 +21,7 @@ export default async function ResumePage({ params }: { params: Promise<{ id: str // The editor gets the photo whether or not this document shows it, so the // toggle in the design popover previews instantly. const profile = await getProfile(user.id); - const googleConnection = await getGoogleConnection(user.id); + const googleConnection = await accountAccess(user.id); // The base this variant was tailored from, for the live compare view. A // dangling reference (base deleted) resolves to null and the editor simply @@ -61,9 +61,7 @@ export default async function ResumePage({ params }: { params: Promise<{ id: str company: application.company.name, appliedAt: application.appliedAt?.toISOString() ?? null, }))} - googleAccess={ - googleConnection ? { mail: googleConnection.mail, calendar: googleConnection.calendar } : null - } + googleAccess={googleConnection} /> ); } diff --git a/src/app/(app)/settings/admin/page.tsx b/src/app/(app)/settings/admin/page.tsx index 837325b..f46f37d 100644 --- a/src/app/(app)/settings/admin/page.tsx +++ b/src/app/(app)/settings/admin/page.tsx @@ -14,6 +14,7 @@ import { emailIsConfigured, getSettings, googleIsConfigured, + microsoftIsConfigured, listVariables, } from "@/lib/settings"; import { billedUserCount } from "@/lib/billing"; @@ -207,6 +208,10 @@ export default async function AdminPage() { configured: googleIsConfigured(settings), redirectUri: `${settings.publicUrl || `${proto}://${host}`}/api/auth/google/callback`, }} + microsoft={{ + configured: microsoftIsConfigured(settings), + redirectUri: `${settings.publicUrl || `${proto}://${host}`}/api/auth/microsoft/callback`, + }} email={{ configured: emailReady, fromEmail: settings.resendFromEmail, diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 58421ea..e861b94 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -23,8 +23,8 @@ import { listSkills } from "@/lib/skills"; import { toolsFor, promptsFor } from "@/lib/mcp/tools"; import { guessClient } from "@/lib/mcp/clients"; import { MANUAL_URL } from "@/lib/links"; -import { getSettings, googleIsConfigured } from "@/lib/settings"; -import { getGoogleConnection } from "@/lib/data/google"; +import { getSettings, googleIsConfigured, microsoftIsConfigured } from "@/lib/settings"; +import { listLinkedAccounts } from "@/lib/data/accounts"; import { isGoogleRefusal, refusalMessage } from "@/lib/google"; export const dynamic = "force-dynamic"; @@ -49,11 +49,11 @@ const TABS = ["connections", "account", "appearance"] as const; export default async function SettingsPage({ searchParams, }: { - searchParams: Promise<{ tab?: string; google?: string }>; + searchParams: Promise<{ tab?: string; account?: string }>; }) { const user = await requireUser(); const headerList = await headers(); - const { tab, google: googleOutcome } = await searchParams; + const { tab, account: accountOutcome } = await searchParams; const host = headerList.get("x-forwarded-host") ?? headerList.get("host") ?? "localhost:3000"; const proto = @@ -62,22 +62,24 @@ export default async function SettingsPage({ // Nobody should ever land here with nothing to copy. await ensureDefaultConnection(user.id); - const [connections, profile, skills, settings, googleConnection] = await Promise.all([ + const [connections, profile, skills, settings, linkedAccounts] = await Promise.all([ listConnections(user.id), getProfile(user.id), listSkills(), getSettings(), - getGoogleConnection(user.id), + listLinkedAccounts(user.id), ]); // What the consent screen came back with, as a fixed code — never text from // the query string, for the same reason the sign-in page refuses it. - const googleNotice = - googleOutcome === "connected" - ? { ok: true, message: "Google connected. Every contact, company and application page now shows its email and calendar." } - : googleOutcome && isGoogleRefusal(googleOutcome) - ? { ok: false, message: refusalMessage(googleOutcome) } - : null; + const accountNotice = + accountOutcome === "connected" + ? { ok: true, message: "Account connected. Every contact, company and application page now shows its email and calendar." } + : accountOutcome === "not_set_up" + ? { ok: false, message: "That provider is not set up on this instance yet. An admin adds it under Admin → Configuration." } + : accountOutcome && isGoogleRefusal(accountOutcome) + ? { ok: false, message: refusalMessage(accountOutcome) } + : null; const visibleTools = toolsFor(user); const visiblePrompts = promptsFor(user); @@ -86,11 +88,10 @@ export default async function SettingsPage({ // ?tab= so other screens can send someone to the right one — the resume // editor points at the photo, which lives under Account. `google` was a tab // of its own for one release and is a tile on Connections now; the old - // address still lands on the right tile, opened. + // address falls through to Connections. const active = TABS.includes(tab as (typeof TABS)[number]) ? (tab as (typeof TABS)[number]) : "connections"; - const focusGoogle = tab === "google" || Boolean(googleOutcome); return ( @@ -143,20 +144,24 @@ export default async function SettingsPage({ adminToolCount={visibleTools.filter((tool) => tool.adminOnly).length} isAdmin={admin} promptCount={visiblePrompts.length} - google={{ - connection: googleConnection - ? { - email: googleConnection.email, - mail: googleConnection.mail, - calendar: googleConnection.calendar, - connectedAt: googleConnection.connectedAt.toISOString(), - lastUsedAt: googleConnection.lastUsedAt?.toISOString() ?? null, - lastError: googleConnection.lastError, - } - : null, - ready: googleIsConfigured(settings), - notice: googleNotice, - focus: focusGoogle, + accounts={{ + list: linkedAccounts.map((account) => ({ + id: account.id, + provider: account.provider, + providerLabel: account.providerLabel, + email: account.email, + label: account.label, + mail: account.mail, + calendar: account.calendar, + imapHost: account.imapHost, + caldavUrl: account.caldavUrl, + connectedAt: account.connectedAt.toISOString(), + lastUsedAt: account.lastUsedAt?.toISOString() ?? null, + lastError: account.lastError, + })), + googleReady: googleIsConfigured(settings), + microsoftReady: microsoftIsConfigured(settings), + notice: accountNotice, }} /> diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts index 0db5746..7d897f6 100644 --- a/src/app/api/auth/google/callback/route.ts +++ b/src/app/api/auth/google/callback/route.ts @@ -12,7 +12,8 @@ import { type GoogleRefusal, } from "@/lib/google"; import { recordSystemEvent } from "@/lib/data/system"; -import { connectGoogleAccount } from "@/lib/data/google"; +import { connectOAuthAccount } from "@/lib/data/accounts"; +import { googleFeatures } from "@/lib/accounts/google"; import { baseUrlFrom } from "@/lib/request-url"; /** @@ -45,7 +46,7 @@ export async function GET(request: NextRequest) { const target = stored?.data ? new URL("/settings?tab=connections", request.url) : new URL("/login", request.url); - target.searchParams.set(stored?.data ? "google" : "error", reason); + target.searchParams.set(stored?.data ? "account" : "error", reason); const response = NextResponse.redirect(target); response.cookies.delete(GOOGLE_STATE_COOKIE); return response; @@ -94,20 +95,17 @@ export async function GET(request: NextRequest) { const user = await getCurrentUser(); if (!user) return fail("expired_state"); if (!identity.grant.refreshToken) return fail("no_refresh_token"); - try { - await connectGoogleAccount(user.id, { - email: identity.email, - googleId: identity.sub, - scopes: identity.grant.scopes, - refreshToken: identity.grant.refreshToken, - accessToken: identity.grant.accessToken, - expiresAt: identity.grant.expiresAt, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (/Neither Gmail nor Calendar/.test(message)) return fail("no_scopes"); - throw error; - } + const features = googleFeatures(identity.grant.scopes); + if (features.length === 0) return fail("no_scopes"); + await connectOAuthAccount(user.id, { + provider: "GOOGLE", + email: identity.email, + externalId: identity.sub, + features, + refreshToken: identity.grant.refreshToken, + accessToken: identity.grant.accessToken, + expiresAt: identity.grant.expiresAt, + }); await recordSystemEvent({ level: "INFO", source: "google.data", @@ -115,7 +113,7 @@ export async function GET(request: NextRequest) { userEmail: user.email, }); const target = new URL("/settings?tab=connections", request.url); - target.searchParams.set("google", "connected"); + target.searchParams.set("account", "connected"); const response = NextResponse.redirect(target); response.cookies.delete(GOOGLE_STATE_COOKIE); return response; diff --git a/src/app/api/auth/microsoft/callback/route.ts b/src/app/api/auth/microsoft/callback/route.ts new file mode 100644 index 0000000..049c096 --- /dev/null +++ b/src/app/api/auth/microsoft/callback/route.ts @@ -0,0 +1,106 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getSettings, microsoftIsConfigured } from "@/lib/settings"; +import { getCurrentUser } from "@/lib/auth"; +import { unpackState, type GoogleRefusal } from "@/lib/google"; +import { + MICROSOFT_STATE_COOKIE, + exchangeMicrosoftCode, + microsoftFeatures, + microsoftProfile, + microsoftRedirectUri, +} from "@/lib/accounts/microsoft"; +import { connectOAuthAccount } from "@/lib/data/accounts"; +import { recordSystemEvent } from "@/lib/data/system"; +import { baseUrlFrom } from "@/lib/request-url"; + +/** + * Where Microsoft sends the browser back. + * + * Every exit clears the state cookie, so one authorization code can never be + * replayed against a second attempt. Every exit also lands on the Connections + * tab with a fixed outcome code — the same list the sign-in page uses, for + * the same reason: a callback that echoes text into its own page is a + * phishing primitive. + */ +export async function GET(request: NextRequest) { + const settings = await getSettings(); + const url = request.nextUrl; + + const fail = (reason: GoogleRefusal) => { + const target = new URL("/settings?tab=connections", request.url); + target.searchParams.set("account", reason); + const response = NextResponse.redirect(target); + response.cookies.delete(MICROSOFT_STATE_COOKIE); + return response; + }; + + if (!microsoftIsConfigured(settings)) return fail("not_set_up"); + + const stored = unpackState(request.cookies.get(MICROSOFT_STATE_COOKIE)?.value, settings.microsoftClientSecret); + const returned = url.searchParams.get("state"); + if (!stored || !returned || stored.state !== returned) return fail("expired_state"); + + const denied = url.searchParams.get("error"); + if (denied) { + if (denied !== "access_denied") { + await recordSystemEvent({ + level: "WARN", + source: "microsoft.data", + message: "Microsoft refused a connect", + detail: `${denied}: ${url.searchParams.get("error_description") ?? ""}`.slice(0, 300), + }); + } + return fail(denied === "access_denied" ? "cancelled" : "failed"); + } + + const code = url.searchParams.get("code"); + if (!code) return fail("failed"); + + const user = await getCurrentUser(); + if (!user) return fail("expired_state"); + + const baseUrl = settings.publicUrl || baseUrlFrom(request.headers, request.url); + + try { + const grant = await exchangeMicrosoftCode({ + clientId: settings.microsoftClientId, + clientSecret: settings.microsoftClientSecret, + code, + redirectUri: microsoftRedirectUri(baseUrl), + }); + if (!grant.refreshToken) return fail("no_refresh_token"); + const features = microsoftFeatures(grant.scopes); + if (features.length === 0) return fail("no_scopes"); + + const profile = await microsoftProfile(grant.accessToken); + await connectOAuthAccount(user.id, { + provider: "MICROSOFT", + email: profile.email, + externalId: profile.id, + features, + refreshToken: grant.refreshToken, + accessToken: grant.accessToken, + expiresAt: grant.expiresAt, + }); + await recordSystemEvent({ + level: "INFO", + source: "microsoft.data", + message: "Connected a Microsoft 365 account", + userEmail: user.email, + }); + + const target = new URL("/settings?tab=connections", request.url); + target.searchParams.set("account", "connected"); + const response = NextResponse.redirect(target); + response.cookies.delete(MICROSOFT_STATE_COOKIE); + return response; + } catch (error) { + await recordSystemEvent({ + level: "ERROR", + source: "microsoft.data", + message: "Connecting a Microsoft 365 account failed", + detail: error instanceof Error ? error.message : "Unknown error", + }); + return fail("failed"); + } +} diff --git a/src/app/api/auth/microsoft/route.ts b/src/app/api/auth/microsoft/route.ts new file mode 100644 index 0000000..3744831 --- /dev/null +++ b/src/app/api/auth/microsoft/route.ts @@ -0,0 +1,49 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getSettings, microsoftIsConfigured } from "@/lib/settings"; +import { getCurrentUser } from "@/lib/auth"; +import { newStateValues, packState } from "@/lib/google"; +import { MICROSOFT_STATE_COOKIE, microsoftAuthUrl, microsoftRedirectUri } from "@/lib/accounts/microsoft"; +import { baseUrlFrom } from "@/lib/request-url"; + +/** + * Start connecting a Microsoft 365 or Outlook.com account, for reading mail + * and calendar. Never a sign-in: the grant has to land on an account, and + * the only account it may land on is the one in the session cookie, so a + * visitor who is not signed in is sent to sign in first. + * + * The state cookie is the same signed, httpOnly cookie the Google flow uses, + * signed with this client's secret rather than Google's — see the note in + * src/lib/google.ts for why nothing about a half-finished consent is worth a + * row in the database. + */ + +export async function GET(request: NextRequest) { + const settings = await getSettings(); + if (!microsoftIsConfigured(settings)) { + return NextResponse.redirect(new URL("/settings?tab=connections&account=not_set_up", request.url)); + } + const user = await getCurrentUser(); + if (!user) { + return NextResponse.redirect(new URL("/login?next=/settings?tab=connections", request.url)); + } + + const baseUrl = settings.publicUrl || baseUrlFrom(request.headers, request.url); + const state = newStateValues("/settings?tab=connections", false, true); + + const response = NextResponse.redirect( + microsoftAuthUrl({ + clientId: settings.microsoftClientId, + redirectUri: microsoftRedirectUri(baseUrl), + state: state.state, + nonce: state.nonce, + }), + ); + response.cookies.set(MICROSOFT_STATE_COOKIE, packState(state, settings.microsoftClientSecret), { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 600, + }); + return response; +} diff --git a/src/components/admin/configuration-panel.tsx b/src/components/admin/configuration-panel.tsx index 8b61959..73c99db 100644 --- a/src/components/admin/configuration-panel.tsx +++ b/src/components/admin/configuration-panel.tsx @@ -67,11 +67,13 @@ type Variable = { export function ConfigurationPanel({ variables, google, + microsoft, email, billing, }: { variables: Variable[]; google: { configured: boolean; redirectUri: string }; + microsoft: { configured: boolean; redirectUri: string }; email: { configured: boolean; fromEmail: string; @@ -302,6 +304,7 @@ export function ConfigurationPanel({ searching for a setting is looking for one row, and four numbered steps between them and it is the opposite of help. */} {!filtering && group.name === "Sign-in" && } + {!filtering && group.name === "Accounts" && } {!filtering && group.name === "Email" && !email.configured && } {!filtering && group.name === "Billing" && } @@ -433,6 +436,7 @@ export function ConfigurationPanel({ const GROUP_BLURB: Record = { Instance: "What this instance is called and where it lives. Every invitation link, published resume and webhook URL is built from the public URL.", "Sign-in": "Everyone can always sign in with an email and password. Adding a Google client turns on a Continue with Google button as well — existing members and anyone holding an invitation can use it straight away. The same client is what lets each person connect their own Gmail and Calendar under Settings → Connections.", + Accounts: "What members can connect for the app to read their mail and calendar. Google uses the sign-in client above. Microsoft 365 and Outlook.com need an app registration in Microsoft Entra, set here. Any other provider — Fastmail, iCloud, Yahoo, a self-hosted server — connects by IMAP and CalDAV with an app password and needs nothing from you.", Email: "Invitations go out through Resend. Everything works without it — creating an invite just gives you a link to send yourself.", Billing: "Optional, for hosting other people here for a fee. Someone who pays through your Stripe payment link is invited automatically; a lapsed subscription suspends them, data kept, and paying again turns them back on.", Custom: "Variables added by hand. Nothing in the app reads these unless something was written to look for them.", @@ -469,6 +473,72 @@ function StatusBadge({ ); } +function MicrosoftSetup({ redirectUri }: { redirectUri: string }) { + return ( +
+
    + {[ + <> + In the{" "} + + Microsoft Entra admin center + + , add an app registration. For supported account types pick{" "} + any organizational directory and personal Microsoft accounts, so + both work and personal mailboxes can connect. + , + <> + Under Authentication add a Web platform with the redirect URI below, + exactly as shown. + , + <> + Under API permissions add the Microsoft Graph delegated permissions{" "} + Mail.Read, Calendars.Read, User.Read and{" "} + offline_access. No admin consent is needed for these. + , + <> + Under Certificates & secrets create a client secret, and paste its{" "} + value (shown once) and the Application (client) ID in below. Entra + secrets expire, two years at most; note the date. + , + ].map((step, index) => ( +
  1. + + {index + 1} + + {step} +
  2. + ))} +
+ +
+ +
+ + {redirectUri} + + +
+
+
+ ); +} + function GoogleSetup({ redirectUri }: { redirectUri: string }) { return (
diff --git a/src/components/client-mark.tsx b/src/components/client-mark.tsx index f26d082..709d329 100644 --- a/src/components/client-mark.tsx +++ b/src/components/client-mark.tsx @@ -1,4 +1,4 @@ -import { CodeIcon, PlugZapIcon, TerminalIcon } from "lucide-react"; +import { CodeIcon, MailIcon, PlugZapIcon, TerminalIcon } from "lucide-react"; import { clientMark } from "@/lib/mcp/marks"; import { cn } from "@/lib/utils"; @@ -16,6 +16,8 @@ const FALLBACK_GLYPHS: Record = { "generic-http": PlugZapIcon, "stdio-bridge": TerminalIcon, raw: CodeIcon, + // An IMAP account is any provider, so it has no brand to draw. + imap: MailIcon, }; export function ClientMark({ diff --git a/src/components/google/correspondence-card.tsx b/src/components/google/correspondence-card.tsx index bf4efeb..6eb7e59 100644 --- a/src/components/google/correspondence-card.tsx +++ b/src/components/google/correspondence-card.tsx @@ -15,12 +15,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { correspondenceAction, emailThreadAction } from "@/server/actions"; -import type { CorrespondenceSubject } from "@/lib/data/google"; +import type { CorrespondenceSubject } from "@/lib/data/accounts"; import { agoDay, cn } from "@/lib/utils"; /** - * The threads and meetings in somebody's own Google account that touch one - * record — a contact, a company, an application, a resume. + * The threads and meetings in somebody's own mail and calendar accounts that + * touch one record — a contact, a company, an application, a resume. * * Fetched after the page paints, never during it: this is a round trip to * Google, and a contact page should not wait on Gmail to show a phone @@ -109,7 +109,7 @@ export function CorrespondenceCard({
); @@ -167,14 +168,13 @@ function LoadedView({ data, access }: { data: Loaded; access: NonNullable {!access.mail ? ( - Gmail was not allowed when Google was connected.{" "} + None of your connected accounts provides mail.{" "} - Reconnect - {" "} - and tick it. + Connections + ) : data.mail === null ? ( - {data.warnings.find((w) => w.startsWith("Mail:")) ?? "Gmail did not answer."} + {data.warnings.find((w) => w.startsWith("Mail:")) ?? "Mail did not answer."} ) : data.mail.length === 0 ? ( No threads in the last year match. ) : ( @@ -189,11 +189,10 @@ function LoadedView({ data, access }: { data: Loaded; access: NonNullable {!access.calendar ? ( - Calendar was not allowed when Google was connected.{" "} + None of your connected accounts provides a calendar.{" "} - Reconnect - {" "} - and tick it. + Connections + ) : data.calendar === null ? ( diff --git a/src/components/settings/accounts.tsx b/src/components/settings/accounts.tsx new file mode 100644 index 0000000..08ef35c --- /dev/null +++ b/src/components/settings/accounts.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + CalendarIcon, + CheckIcon, + CircleAlertIcon, + LoaderCircleIcon, + MailIcon, + TriangleAlertIcon, + ZapIcon, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"; +import { ClientTile } from "@/components/client-mark"; +import { cn } from "@/lib/utils"; +import { agoDay } from "@/lib/utils"; +import { + connectImapAccountAction, + disconnectAccountAction, + renameAccountAction, + testAccountAction, +} from "@/server/actions"; + +/** + * The account side of Settings → Connections: what the workspace reads on + * your behalf. + * + * Three ways in, one shape out. Google and Microsoft 365 are a consent + * screen, so their "connect" is a link; IMAP and CalDAV are a form, because + * an app password has to be typed somewhere. All three land as the same + * tile, open the same slide-over, and are tested and disconnected the same + * way. Nothing read through any of them is stored, and the copy says so + * wherever a person is about to hand over an inbox. + */ + +export type AccountView = { + id: string; + provider: "GOOGLE" | "MICROSOFT" | "IMAP"; + providerLabel: string; + email: string; + label: string; + mail: boolean; + calendar: boolean; + imapHost: string; + caldavUrl: string; + connectedAt: string; + lastUsedAt: string | null; + lastError: string; +}; + +/** The tile's mark id and the words for each half, per provider. */ +export function markFor(provider: AccountView["provider"]) { + return provider.toLowerCase(); +} + +export function featureWords(provider: AccountView["provider"]) { + return { + mail: provider === "GOOGLE" ? "Gmail" : provider === "MICROSOFT" ? "Outlook" : "IMAP", + calendar: provider === "IMAP" ? "CalDAV" : "Calendar", + }; +} + +// --------------------------------------------------------------------------- +// One account +// --------------------------------------------------------------------------- + +export function AccountSheet({ + account, + open, + onOpenChange, + onReconnectImap, +}: { + account: AccountView; + open: boolean; + onOpenChange: (open: boolean) => void; + /** Reconnecting an IMAP account is the form again, prefilled. */ + onReconnectImap: (account: AccountView) => void; +}) { + const router = useRouter(); + const [label, setLabel] = useState(account.label); + const [test, setTest] = useState< + | null + | { error: string } + | { mail: { ok: boolean; detail: string } | null; calendar: { ok: boolean; detail: string } | null } + >(null); + const [testing, startTest] = useTransition(); + const [pending, startTransition] = useTransition(); + const words = featureWords(account.provider); + + const runTest = () => + startTest(async () => { + setTest(null); + const result = await testAccountAction(account.id); + setTest(result.ok ? { mail: result.mail, calendar: result.calendar } : { error: result.error }); + router.refresh(); + }); + + const commitLabel = () => + startTransition(async () => { + if (label.trim() === account.label) return; + await renameAccountAction(account.id, label); + toast.success("Renamed"); + }); + + const remove = () => + startTransition(async () => { + await disconnectAccountAction(account.id); + toast.success(`${account.email} disconnected`); + onOpenChange(false); + }); + + const reconnectHref = + account.provider === "GOOGLE" + ? "/api/auth/google?data=1" + : account.provider === "MICROSOFT" + ? "/api/auth/microsoft" + : null; + + return ( + + +
+ +
+ + {account.label || account.providerLabel} + + + {account.email} + {" · "} + connected {agoDay(account.connectedAt).toLowerCase()} + {account.lastUsedAt + ? ` · last read ${agoDay(account.lastUsedAt).toLowerCase()}` + : " · nothing read yet"} + +
+ +
+ + {test && "error" in test && ( +

+ {test.error} +

+ )} + + {account.lastError && !(test && !("error" in test)) && ( +

+ + The last read failed: {account.lastError} +

+ )} + +
+
    + } + label={words.mail} + detail={account.provider === "IMAP" && account.imapHost ? account.imapHost : undefined} + result={test && !("error" in test) ? test.mail : null} + > + Threads with the people and companies on your pipeline, matched by their address and + domain. Searchable by an assistant. + + } + label={words.calendar} + detail={account.provider === "IMAP" && account.caldavUrl ? account.caldavUrl : undefined} + result={test && !("error" in test) ? test.calendar : null} + > + Interviews and calls with anyone on your pipeline, on the pipeline's calendar + view and next to the application. + +
+ + {(!account.mail || !account.calendar) && ( +

+ {account.provider === "IMAP" + ? "The missing half was left out when this was connected. Reconnect and fill it in to turn it on." + : "The missing half was unticked on the consent screen. Reconnect and tick it to turn it on."} +

+ )} + + + +
+ + setLabel(event.target.value)} + onBlur={commitLabel} + onKeyDown={(event) => { + if (event.key === "Enter") commitLabel(); + }} + placeholder={account.providerLabel} + className="max-w-64" + /> +

What the tile says. "Work", "Old address".

+
+ + + +
+

+ Disconnecting {account.provider === "GOOGLE" ? "revokes the token at Google and " : ""} + deletes the credential here. Nothing else changes: nothing from this account was ever + stored. +

+
+ {reconnectHref ? ( + + ) : ( + + )} + +
+
+
+
+
+ ); +} + +function Half({ + on, + icon, + label, + detail, + result, + children, +}: { + on: boolean; + icon: React.ReactNode; + label: string; + detail?: string; + result: { ok: boolean; detail: string } | null; + children: React.ReactNode; +}) { + return ( +
  • + + {icon} + +
    +

    + {label} + {on ? "on" : "not connected"} + {detail && {detail}} +

    +

    {children}

    + {result && ( +

    + {result.ok ? ( + + ) : ( + + )} + {result.detail} +

    + )} +
    +
  • + ); +} + +// --------------------------------------------------------------------------- +// IMAP and CalDAV +// --------------------------------------------------------------------------- + +type Preset = { id: string; name: string; imapHost: string; imapPort: number; caldavUrl: string; hint: string }; + +const PRESETS: Preset[] = [ + { + id: "fastmail", + name: "Fastmail", + imapHost: "imap.fastmail.com", + imapPort: 993, + caldavUrl: "https://caldav.fastmail.com/", + hint: "Settings → Privacy & Security → Integrations → New app password, with Mail and Calendars access. One password does both.", + }, + { + id: "icloud", + name: "iCloud", + imapHost: "imap.mail.me.com", + imapPort: 993, + caldavUrl: "https://caldav.icloud.com/", + hint: "appleid.apple.com → Sign-In and Security → App-Specific Passwords. Username is your full iCloud address. One password does both.", + }, + { + id: "yahoo", + name: "Yahoo", + imapHost: "imap.mail.yahoo.com", + imapPort: 993, + caldavUrl: "https://caldav.calendar.yahoo.com/", + hint: "Account Security → Generate app password. Username is your full address.", + }, + { id: "custom", name: "Something else", imapHost: "", imapPort: 993, caldavUrl: "", hint: "" }, +]; + +export function ImapSheet({ + open, + onOpenChange, + prefill, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Reconnecting: the account's address and servers, never its passwords. */ + prefill: AccountView | null; +}) { + const router = useRouter(); + const [preset, setPreset] = useState( + prefill ? (PRESETS.find((p) => p.imapHost && p.imapHost === prefill.imapHost)?.id ?? "custom") : "fastmail", + ); + const [form, setForm] = useState({ + email: prefill?.email ?? "", + label: prefill?.label ?? "", + imapHost: prefill?.imapHost ?? PRESETS[0].imapHost, + imapPort: "993", + imapUsername: "", + imapPassword: "", + caldavUrl: prefill?.caldavUrl ?? PRESETS[0].caldavUrl, + caldavUsername: "", + caldavPassword: "", + }); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + const chosen = PRESETS.find((p) => p.id === preset) ?? PRESETS[PRESETS.length - 1]; + + const set = (patch: Partial) => setForm((prev) => ({ ...prev, ...patch })); + + const pick = (id: string) => { + setPreset(id); + const next = PRESETS.find((p) => p.id === id); + if (next && next.id !== "custom") { + set({ imapHost: next.imapHost, imapPort: String(next.imapPort), caldavUrl: next.caldavUrl }); + } + }; + + const submit = () => + startTransition(async () => { + setError(null); + const result = await connectImapAccountAction({ + email: form.email, + label: form.label, + imapHost: form.imapHost, + imapPort: Number(form.imapPort) || 993, + imapUsername: form.imapUsername, + imapPassword: form.imapPassword, + caldavUrl: form.caldavUrl, + caldavUsername: form.caldavUsername, + caldavPassword: form.caldavPassword, + }); + if (!result.ok) { + setError(result.error); + return; + } + toast.success(`${result.account.email} connected`); + onOpenChange(false); + router.refresh(); + }); + + return ( + + + + {prefill ? "Reconnect" : "Connect"} by IMAP and CalDAV + + + Use an app password from your provider's security settings, never + the account password. Both servers are logged in to before anything is saved. Leave + either half blank to connect only the other. + + +
    +
    + {PRESETS.map((p) => ( + + ))} +
    + {chosen.hint &&

    {chosen.hint}

    } + +
    + set({ email })} placeholder="you@fastmail.com" type="email" autoFocus={!prefill} /> + set({ label })} placeholder="Personal" /> +
    + +
    +
    + Mail +
    +
    + set({ imapHost })} placeholder="imap.example.com" /> + set({ imapPort })} placeholder="993" inputMode="numeric" /> +
    +
    + set({ imapUsername })} placeholder="Defaults to the address" /> + set({ imapPassword })} type="password" autoComplete="new-password" /> +
    +
    + +
    +
    + Calendar +
    + set({ caldavUrl })} placeholder="https://caldav.example.com/" /> +
    + set({ caldavUsername })} placeholder="Same as mail" /> + set({ caldavPassword })} type="password" placeholder="Same as mail" autoComplete="new-password" /> +
    +
    + + {error && ( +

    + {error} +

    + )} + +
    + + +
    +
    +
    +
    + ); +} + +function Field({ + label, + value, + onChange, + ...rest +}: { + label: string; + value: string; + onChange: (value: string) => void; +} & Omit, "value" | "onChange">) { + const id = `imap-${label.toLowerCase().replace(/[^a-z]+/g, "-")}`; + return ( +
    + + onChange(event.target.value)} {...rest} /> +
    + ); +} diff --git a/src/components/settings/connections-panel.tsx b/src/components/settings/connections-panel.tsx index 9b29b62..3eb883c 100644 --- a/src/components/settings/connections-panel.tsx +++ b/src/components/settings/connections-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowUpRightIcon, @@ -25,7 +25,13 @@ import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ClientMark, ClientTile } from "@/components/client-mark"; -import { GoogleDetails, type GoogleConnectionView } from "@/components/settings/google-panel"; +import { + AccountSheet, + ImapSheet, + featureWords, + markFor, + type AccountView, +} from "@/components/settings/accounts"; import { cn } from "@/lib/utils"; import { MCP_CLIENTS, clientName } from "@/lib/mcp/clients"; import { @@ -40,8 +46,8 @@ import { * Everything wired to this workspace, as a grid of tiles. * * Two directions of wiring share the screen. Assistants read and write the - * workspace over MCP; accounts — Google today — are what the workspace reads - * on your behalf. They are drawn the same way, a brand mark on a tile with a + * workspace over MCP; accounts — Google, Microsoft 365, anything with IMAP + * and CalDAV — are what the workspace reads on your behalf. They are drawn the same way, a brand mark on a tile with a * one-line status, because the question a person brings here is the same for * both: what is connected, is it working, and how do I add or remove one. * @@ -60,14 +66,13 @@ export type ConnectionRow = { lastUsedFrom: string; }; -export type GoogleTileProps = { - connection: GoogleConnectionView | null; - /** Whether an admin has configured a Google OAuth client at all. */ - ready: boolean; - /** The outcome of a connect that just came back from Google, if one did. */ +export type AccountsProps = { + list: AccountView[]; + /** Whether an admin has configured each consent-screen provider. */ + googleReady: boolean; + microsoftReady: boolean; + /** The outcome of a connect that just came back from a provider, if one did. */ notice: { ok: boolean; message: string } | null; - /** Open the Google slide-over on arrival — the callback lands here. */ - focus: boolean; }; function ago(iso: string | null) { @@ -467,6 +472,63 @@ function ConnectionSheet({ // Picking a client // --------------------------------------------------------------------------- +/** One way to connect an account: a link out to a consent screen, a form, or a promise. */ +function AccountOption({ + mark, + name, + tagline, + href, + onClick, + disabled, + soon, +}: { + mark: string; + name: string; + tagline: string; + href?: string; + onClick?: () => void; + /** Why it cannot be used right now, in a sentence. */ + disabled?: string; + soon?: boolean; +}) { + const body = ( + <> + +
    +
    + {name} + {soon && ( + + Coming soon + + )} +
    +
    {disabled ?? tagline}
    +
    + {!disabled && !soon && } + + ); + const className = cn( + "flex items-center gap-3 rounded-xl border p-3 text-left transition-colors", + disabled || soon + ? "opacity-60" + : "hover:border-primary/40 hover:bg-accent/40 focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none", + ); + if (disabled || soon) return
    {body}
    ; + if (href) { + return ( + + {body} + + ); + } + return ( + + ); +} + /** * The library: everything that can be wired to this workspace, in one place. * @@ -487,8 +549,9 @@ function LibrarySheet({ connections, onPick, onOpenConnection, - google, - onOpenGoogle, + accounts, + onOpenAccount, + onImap, pending, }: { open: boolean; @@ -496,8 +559,9 @@ function LibrarySheet({ connections: ConnectionRow[]; onPick: (client: string) => void; onOpenConnection: (id: string) => void; - google: GoogleTileProps; - onOpenGoogle: () => void; + accounts: AccountsProps; + onOpenAccount: (id: string) => void; + onImap: () => void; pending: boolean; }) { const products = MCP_CLIENTS.filter((entry) => entry.category !== "any"); @@ -565,36 +629,61 @@ function LibrarySheet({

    Live and read-only. Nothing is copied into this instance — every screen asks at - the moment you open it, and disconnecting deletes the only thing held. + the moment you open it, and disconnecting deletes the only thing held. Connect + more than one if recruiters write to more than one.

    + {accounts.list.length > 0 && ( +
    + {accounts.list.map((account) => ( + + ))} +
    + )}
    - + + + +
    +
    + Where you get nudged +
    +
    + +
    - {/* Said out loud rather than left as an empty grid: one account is - the whole list today, and a person who came here looking for a - second one should not have to wonder whether it failed to load. */} -

    - Google is the only account Hired reads from today. -

    @@ -613,7 +702,7 @@ export function ConnectionsPanel({ adminToolCount, isAdmin, promptCount, - google, + accounts, }: { baseUrl: string; connections: ConnectionRow[]; @@ -621,20 +710,15 @@ export function ConnectionsPanel({ adminToolCount: number; isAdmin: boolean; promptCount: number; - google: GoogleTileProps; + accounts: AccountsProps; }) { const [pending, startTransition] = useTransition(); const [picking, setPicking] = useState(false); const [openId, setOpenId] = useState(null); - const [googleOpen, setGoogleOpen] = useState(false); + const [openAccountId, setOpenAccountId] = useState(null); + const [imap, setImap] = useState<{ open: boolean; prefill: AccountView | null }>({ open: false, prefill: null }); const used = useMemo(() => connections.filter((c) => c.lastUsedAt).length, [connections]); - // The callback from Google lands on this tab with `focus` set: open the tile - // it came back to, so the outcome is in front of the person, not a click away. - useEffect(() => { - if (google.focus) setGoogleOpen(true); - }, [google.focus]); - const add = (client: string) => startTransition(async () => { const created = await createConnectionAction({ client, name: clientName(client) }); @@ -644,23 +728,8 @@ export function ConnectionsPanel({ }); const openConnection = connections.find((connection) => connection.id === openId) ?? null; + const openAccount = accounts.list.find((account) => account.id === openAccountId) ?? null; - const googleStatus = !google.ready ? ( - Needs an admin - ) : !google.connection ? ( - Not connected - ) : google.connection.lastError ? ( - Needs reconnecting - ) : ( - - - Gmail - - - Calendar - - - ); return (
    @@ -670,7 +739,12 @@ export function ConnectionsPanel({

    {connections.length === 1 ? "1 assistant" : `${connections.length} assistants`} {used > 0 && connections.length > 1 && `, ${used} in use`} ·{" "} - {google.connection ? "Google connected" : "Google not connected"} · {toolCount} tools + {accounts.list.length === 0 + ? "no accounts" + : accounts.list.length === 1 + ? "1 account" + : `${accounts.list.length} accounts`}{" "} + · {toolCount} tools {isAdmin && adminToolCount > 0 && ({adminToolCount} admin)} · {promptCount}{" "} workflows

    @@ -685,21 +759,21 @@ export function ConnectionsPanel({
    - {google.notice && ( + {accounts.notice && (
    - {google.notice.ok ? ( + {accounts.notice.ok ? ( ) : ( )} - {google.notice.message} + {accounts.notice.message}
    )} @@ -732,13 +806,32 @@ export function ConnectionsPanel({ ); })} - setGoogleOpen(true)} - /> + {accounts.list.map((account) => { + const words = featureWords(account.provider); + return ( + Needs reconnecting + ) : ( + + + {words.mail} + + + {words.calendar} + + + ) + } + onClick={() => setOpenAccountId(account.id)} + /> + ); + })} - - ); - } - - return ( -
    -
    -
    -

    {connection.email}

    -

    - Connected {agoDay(connection.connectedAt).toLowerCase()} - {connection.lastUsedAt - ? ` · last read ${agoDay(connection.lastUsedAt).toLowerCase()}` - : " · nothing read yet"} -

    -
    - -
    - - {connection.lastError && ( -

    - - The last read failed: {connection.lastError} Reconnect to fix it. -

    - )} - - - -
      - } label="Gmail"> - Threads with the people and companies on your pipeline, matched by their address and - domain. Searchable by an assistant. - - } label="Calendar"> - Interviews and calls with anyone on your pipeline, on the pipeline's calendar view - and next to the application. - -
    - {(!connection.mail || !connection.calendar) && ( -

    - The unticked one was left off Google's consent screen. Reconnect and tick it to - turn it on. -

    - )} - - - -
    -

    - Disconnecting revokes the token at Google and deletes it here. Nothing else changes. -

    - -
    -
    - ); -} - -function Granted({ - on, - icon, - label, - children, -}: { - /** True granted, false refused, null not connected yet. */ - on: boolean | null; - icon: React.ReactNode; - label: string; - children: React.ReactNode; -}) { - return ( -
  • - - {icon} - -
    -

    - {label} - {on !== null && ( - {on ? "on" : "not granted"} - )} -

    -

    {children}

    -
    -
  • - ); -} diff --git a/src/lib/accounts/caldav.ts b/src/lib/accounts/caldav.ts new file mode 100644 index 0000000..a5fc289 --- /dev/null +++ b/src/lib/accounts/caldav.ts @@ -0,0 +1,215 @@ +import { createDAVClient, type DAVCalendar } from "tsdav"; +import ICAL from "ical.js"; +import { + ProviderError, + type CalendarAttendee, + type CalendarEvent, + type CalendarReader, + type CalendarWindow, + type MailParticipant, +} from "@/lib/accounts/types"; +import { clip, findMeetingLink, mentions, normalise } from "@/lib/accounts/text"; + +/** + * Any calendar that speaks CalDAV: iCloud, Fastmail, Nextcloud, Radicale, + * and Google's own CalDAV endpoint for the person who would rather not + * hand over an OAuth token. + * + * tsdav does the discovery — the well-known URL, the principal, the + * calendar-home — which is the part that differs between every server and + * the part not worth writing twice. ical.js parses what comes back. + * Recurrences are asked to be expanded server-side (RFC 4791's `expand`), + * and expanded here when a server ignores that, so a weekly standing call + * shows each occurrence either way. + * + * Read-only: only PROPFIND and REPORT are ever issued. + */ + +export type CaldavConfig = { + /** The server, or the account's calendar home. Discovery finds the rest. */ + url: string; + username: string; + password: string; +}; + +async function client(config: CaldavConfig) { + try { + return await createDAVClient({ + serverUrl: config.url, + credentials: { username: config.username, password: config.password }, + authMethod: "Basic", + defaultAccountType: "caldav", + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const auth = /401|403|unauthori[sz]ed|forbidden/i.test(message); + throw new ProviderError( + auth + ? `${config.url} refused the login. Use an app password rather than the account password.` + : `Could not discover a calendar at ${config.url}. ${message}`, + { revoked: auth }, + ); + } +} + +function eventCalendars(calendars: DAVCalendar[]): DAVCalendar[] { + return calendars.filter((calendar) => { + const components = calendar.components ?? []; + return components.length === 0 || components.includes("VEVENT"); + }); +} + +/** Log in, discover, list calendars. The whole test of a CalDAV account. */ +export async function verifyCaldav(config: CaldavConfig): Promise<{ calendars: string[] }> { + const dav = await client(config); + const calendars = eventCalendars(await dav.fetchCalendars()); + if (calendars.length === 0) { + throw new ProviderError(`Signed in to ${config.url}, but it has no calendars.`); + } + return { calendars: calendars.map((calendar) => String(calendar.displayName ?? calendar.url)) }; +} + +export function caldavCalendarReader(config: CaldavConfig): CalendarReader { + return { + async listEvents(window: CalendarWindow) { + const dav = await client(config); + const calendars = eventCalendars(await dav.fetchCalendars()); + const out: CalendarEvent[] = []; + const seen = new Set(); + for (const calendar of calendars) { + let objects; + try { + objects = await dav.fetchCalendarObjects({ + calendar, + timeRange: { start: window.from.toISOString(), end: window.to.toISOString() }, + expand: true, + }); + } catch { + // A server that rejects `expand` answers the plain query. + objects = await dav.fetchCalendarObjects({ + calendar, + timeRange: { start: window.from.toISOString(), end: window.to.toISOString() }, + }); + } + for (const object of objects) { + if (!object.data) continue; + for (const event of parseEvents(String(object.data), window)) { + const key = `${event.id}@${event.start.toISOString()}`; + if (seen.has(key)) continue; + seen.add(key); + if (mentions(searchable(event), window.query)) out.push(event); + } + } + if (out.length >= (window.limit ?? 250)) break; + } + return out.sort((a, b) => a.start.getTime() - b.start.getTime()).slice(0, window.limit ?? 250); + }, + }; +} + +function searchable(event: CalendarEvent): string { + return [ + event.title, + event.description, + event.location, + ...event.attendees.map((a) => `${a.name} ${a.email}`), + event.organizer ? `${event.organizer.name} ${event.organizer.email}` : "", + ].join(" "); +} + +const PARTSTAT: Record = { + ACCEPTED: "accepted", + DECLINED: "declined", + TENTATIVE: "tentative", + "NEEDS-ACTION": "needsAction", +}; + +function mailto(value: unknown): string { + return String(value ?? "") + .replace(/^mailto:/i, "") + .trim() + .toLowerCase(); +} + +function personFrom(property: ICAL.Property | null): MailParticipant | null { + if (!property) return null; + const email = mailto(property.getFirstValue()); + if (!email.includes("@")) return null; + return { name: String(property.getParameter("cn") ?? ""), email }; +} + +function attendeesOf(component: ICAL.Component): CalendarAttendee[] { + return component + .getAllProperties("attendee") + .map((property) => { + const email = mailto(property.getFirstValue()); + if (!email.includes("@")) return null; + const partstat = String(property.getParameter("partstat") ?? "NEEDS-ACTION").toUpperCase(); + return { + name: String(property.getParameter("cn") ?? ""), + email, + response: PARTSTAT[partstat] ?? "needsAction", + self: false, + }; + }) + .filter((attendee): attendee is CalendarAttendee => attendee !== null); +} + +/** Every occurrence inside the window from one .ics, whether the server expanded it or not. */ +function parseEvents(ics: string, window: CalendarWindow): CalendarEvent[] { + let root: ICAL.Component; + try { + root = new ICAL.Component(ICAL.parse(ics)); + } catch { + return []; + } + const out: CalendarEvent[] = []; + for (const vevent of root.getAllSubcomponents("vevent")) { + const event = new ICAL.Event(vevent); + if (String(vevent.getFirstPropertyValue("status") ?? "").toUpperCase() === "CANCELLED") continue; + // An overridden occurrence carries RECURRENCE-ID and is its own VEVENT; + // only a master with an RRULE needs expanding. + if (event.isRecurring() && !event.isRecurrenceException()) { + const iterator = event.iterator(); + let next: ICAL.Time | null; + let guard = 0; + while ((next = iterator.next()) && guard++ < 500) { + const start = next.toJSDate(); + if (start > window.to) break; + const details = event.getOccurrenceDetails(next); + const end = details.endDate.toJSDate(); + if (end < window.from) continue; + out.push(toEvent(vevent, event, start, end, next.isDate)); + } + } else { + const start = event.startDate?.toJSDate(); + if (!start) continue; + const end = event.endDate?.toJSDate() ?? start; + if (end < window.from || start > window.to) continue; + out.push(toEvent(vevent, event, start, end, event.startDate.isDate)); + } + } + return out; +} + +function toEvent(vevent: ICAL.Component, event: ICAL.Event, start: Date, end: Date, allDay: boolean): CalendarEvent { + const description = normalise(String(event.description ?? "")); + const url = String(vevent.getFirstPropertyValue("url") ?? ""); + const conference = String( + vevent.getFirstPropertyValue("x-google-conference") ?? vevent.getFirstPropertyValue("conference") ?? "", + ); + return { + id: event.uid, + title: String(event.summary ?? "").trim() || "(no title)", + description: clip(description, 2000), + location: String(event.location ?? ""), + start, + end, + allDay, + status: String(vevent.getFirstPropertyValue("status") ?? "confirmed").toLowerCase(), + organizer: personFrom(vevent.getFirstProperty("organizer")), + attendees: attendeesOf(vevent), + meetingUrl: findMeetingLink(`${conference} ${url} ${event.location ?? ""} ${description}`), + url: /^https?:/i.test(url) ? url : "", + }; +} diff --git a/src/lib/google-api.ts b/src/lib/accounts/google.ts similarity index 66% rename from src/lib/google-api.ts rename to src/lib/accounts/google.ts index 884795b..14cc1df 100644 --- a/src/lib/google-api.ts +++ b/src/lib/accounts/google.ts @@ -1,4 +1,26 @@ import type { InstanceSettings } from "@/lib/settings"; +import { + PROVIDER_TIMEOUT_MS, + ProviderError, + type AccountFeature, + type CalendarEvent, + type CalendarReader, + type CalendarWindow, + type MailMessage, + type MailReader, + type MailSearch, + type MailThread, + type MailThreadSummary, +} from "@/lib/accounts/types"; +import { + clip, + decodeEntities, + mentions, + mergeParticipants, + normalise, + parseAddresses, + stripHtml, +} from "@/lib/accounts/text"; /** * Gmail and Google Calendar over plain HTTP. @@ -6,9 +28,8 @@ import type { InstanceSettings } from "@/lib/settings"; * Hand-written for the same reason sign-in is: the surface this app uses is * six requests, and the official client is several megabytes that would own * the shape of every response. Everything here takes an access token and - * returns plain objects; who the token belongs to is the data layer's - * business (src/lib/data/google.ts), and nothing in this file touches the - * database. + * returns the shapes in types.ts; who the token belongs to is the data + * layer's business, and nothing in this file touches the database. * * Read-only by construction. The scopes asked for cannot send, label, delete * or accept anything, and no function here issues anything but GET — except @@ -20,27 +41,17 @@ const REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke"; const GMAIL = "https://gmail.googleapis.com/gmail/v1/users/me"; const CALENDAR = "https://www.googleapis.com/calendar/v3"; -/** The two grants, by the name the rest of the app uses for each. */ -export const GOOGLE_DATA_SCOPES = { +/** The two grants, by the feature name the rest of the app uses for each. */ +export const GOOGLE_DATA_SCOPES: Record = { mail: "https://www.googleapis.com/auth/gmail.readonly", calendar: "https://www.googleapis.com/auth/calendar.readonly", -} as const; - -export type GoogleFeature = keyof typeof GOOGLE_DATA_SCOPES; - -/** Every request has a bound; a hung call to Google must not hang a page. */ -const TIMEOUT_MS = 20_000; +}; -export class GoogleApiError extends Error { - /** True when the refresh token is dead: revoked by the person, or the client changed. */ - revoked: boolean; - status: number; - constructor(message: string, options: { revoked?: boolean; status?: number } = {}) { - super(message); - this.name = "GoogleApiError"; - this.revoked = options.revoked ?? false; - this.status = options.status ?? 0; - } +/** Which features a list of granted scopes amounts to. */ +export function googleFeatures(scopes: string[]): AccountFeature[] { + return (Object.keys(GOOGLE_DATA_SCOPES) as AccountFeature[]).filter((feature) => + scopes.includes(GOOGLE_DATA_SCOPES[feature]), + ); } // --------------------------------------------------------------------------- @@ -69,7 +80,7 @@ export async function refreshAccessToken( refresh_token: refreshToken, grant_type: "refresh_token", }), - signal: AbortSignal.timeout(TIMEOUT_MS), + signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS), }); const body = (await response.json().catch(() => ({}))) as Record; if (!response.ok) { @@ -78,13 +89,13 @@ export async function refreshAccessToken( // invalid_grant is Google's one word for every way a refresh token dies: // the person removed the app from their account, the password changed on // a Workspace account with that policy, or the client was rotated. - throw new GoogleApiError(detail || `Google refused to refresh the token (HTTP ${response.status}).`, { + throw new ProviderError(detail || `Google refused to refresh the token (HTTP ${response.status}).`, { revoked: code === "invalid_grant", status: response.status, }); } const accessToken = typeof body.access_token === "string" ? body.access_token : ""; - if (!accessToken) throw new GoogleApiError("Google did not return an access token."); + if (!accessToken) throw new ProviderError("Google did not return an access token."); const seconds = typeof body.expires_in === "number" ? body.expires_in : 3600; return { accessToken, expiresAt: new Date(Date.now() + seconds * 1000) }; } @@ -97,7 +108,7 @@ export async function revokeToken(token: string): Promise { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token }), - signal: AbortSignal.timeout(TIMEOUT_MS), + signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS), }); } catch { // Nothing to do; the row is deleted either way. @@ -107,53 +118,22 @@ export async function revokeToken(token: string): Promise { async function get(token: string, url: string): Promise { const response = await fetch(url, { headers: { authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(TIMEOUT_MS), + signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS), }); if (!response.ok) { const body = (await response.json().catch(() => ({}))) as { error?: { message?: string; status?: string } }; const message = body.error?.message || `Google answered HTTP ${response.status}.`; if (response.status === 403 && /not been used|is disabled|accessNotConfigured/i.test(message)) { - throw new GoogleApiError( + throw new ProviderError( `${message} The admin has to enable this API in the Google Cloud project that owns the OAuth client.`, { status: 403 }, ); } - throw new GoogleApiError(message, { status: response.status, revoked: response.status === 401 }); + throw new ProviderError(message, { status: response.status, revoked: response.status === 401 }); } return (await response.json()) as T; } -// --------------------------------------------------------------------------- -// Addresses -// --------------------------------------------------------------------------- - -export type MailParticipant = { name: string; email: string }; - -/** "Jane Doe , bob@acme.com" → two participants. */ -export function parseAddresses(header: string): MailParticipant[] { - const out: MailParticipant[] = []; - // Split on commas that are not inside quotes. Display names may carry one. - const parts = header.match(/(?:"[^"]*"|[^,])+/g) ?? []; - for (const part of parts) { - const trimmed = part.trim(); - if (!trimmed) continue; - const angle = /^(.*?)<([^>]+)>\s*$/.exec(trimmed); - if (angle) { - out.push({ - name: angle[1].trim().replace(/^"|"$/g, "").trim(), - email: angle[2].trim().toLowerCase(), - }); - } else if (trimmed.includes("@")) { - out.push({ name: "", email: trimmed.replace(/^"|"$/g, "").toLowerCase() }); - } - } - return out; -} - -export function domainOf(email: string): string { - return email.split("@")[1]?.toLowerCase() ?? ""; -} - /** * Where the inbox lives, for a thread link that opens in the right account. * `authuser` picks the signed-in Google account by address, which is what @@ -168,40 +148,6 @@ export function gmailThreadUrl(accountEmail: string, threadId: string) { // Gmail // --------------------------------------------------------------------------- -export type MailThreadSummary = { - id: string; - subject: string; - /** Gmail's own one-line preview of the latest message. */ - snippet: string; - /** Everyone on the thread, the account holder included, deduplicated by address. */ - participants: MailParticipant[]; - /** Who sent the most recent message. */ - lastFrom: MailParticipant | null; - firstMessageAt: Date; - lastMessageAt: Date; - messageCount: number; - unread: boolean; - url: string; -}; - -export type MailMessage = { - id: string; - from: MailParticipant | null; - to: MailParticipant[]; - cc: MailParticipant[]; - date: Date; - subject: string; - /** Plain text. HTML-only messages are stripped to text; long bodies are cut. */ - body: string; -}; - -export type MailThread = { - id: string; - subject: string; - url: string; - messages: MailMessage[]; -}; - type GmailHeader = { name: string; value: string }; type GmailPart = { mimeType?: string; @@ -232,6 +178,38 @@ function messageDate(message: GmailMessage): Date { return Number.isNaN(parsed.getTime()) ? new Date(0) : parsed; } +/** + * The Gmail query for a search. Braces are Gmail's OR group; `from:acme.com` + * matches every address at that domain. Free text passes straight through so + * Gmail's own operators keep working. Nothing is excluded on purpose — a + * rejection that Gmail filed under Promotions is still the rejection. + */ +export function gmailQueryFor(search: MailSearch): string { + const clauses = [ + ...(search.addresses ?? []).flatMap((address) => [`from:${address}`, `to:${address}`, `cc:${address}`]), + ...(search.domains ?? []).flatMap((domain) => [`from:${domain}`, `to:${domain}`, `cc:${domain}`]), + ]; + const parts = [ + clauses.length ? `{${clauses.join(" ")}}` : "", + search.text?.trim() ?? "", + search.newerThanDays ? `newer_than:${search.newerThanDays}d` : "", + ]; + return parts.filter(Boolean).join(" "); +} + +export function googleMailReader(token: string, accountEmail: string): MailReader { + return { + async searchThreads(search) { + const query = gmailQueryFor(search); + if (!query) return []; + return gmailSearchThreads(token, accountEmail, query, search.limit ?? 20); + }, + getThread(threadId) { + return gmailGetThread(token, accountEmail, threadId); + }, + }; +} + /** * Threads matching a Gmail search, newest first, with enough of each to list * it: subject, who is on it, when it last moved. One list call plus one @@ -273,22 +251,17 @@ function summarise(thread: GmailThread, accountEmail: string): MailThreadSummary const first = messages[0]; const last = messages[messages.length - 1]; - const seen = new Map(); - for (const message of messages) { - for (const name of ["From", "To", "Cc"]) { - for (const person of parseAddresses(header(message, name))) { - const existing = seen.get(person.email); - if (!existing) seen.set(person.email, person); - else if (!existing.name && person.name) existing.name = person.name; - } - } - } - return { id: thread.id, subject: header(first, "Subject") || header(last, "Subject") || "(no subject)", snippet: decodeEntities(last.snippet ?? thread.snippet ?? ""), - participants: [...seen.values()], + participants: mergeParticipants( + messages.flatMap((message) => [ + parseAddresses(header(message, "From")), + parseAddresses(header(message, "To")), + parseAddresses(header(message, "Cc")), + ]), + ), lastFrom: parseAddresses(header(last, "From"))[0] ?? null, firstMessageAt: messageDate(first), lastMessageAt: messageDate(last), @@ -306,7 +279,7 @@ export async function gmailGetThread( options: { maxBodyChars?: number } = {}, ): Promise { const thread = await get(token, `${GMAIL}/threads/${encodeURIComponent(threadId)}?format=full`); - const messages = (thread.messages ?? []) + const messages: MailMessage[] = (thread.messages ?? []) .slice() .sort((a, b) => messageDate(a).getTime() - messageDate(b).getTime()) .map((message) => ({ @@ -358,68 +331,10 @@ function decodeBody(part: GmailPart): string { } } -function stripHtml(html: string): string { - return decodeEntities( - html - .replace(//gi, "") - .replace(//gi, "") - .replace(//gi, "\n") - .replace(/<\/(p|div|tr|li|h[1-6]|blockquote)>/gi, "\n") - .replace(/<[^>]+>/g, ""), - ); -} - -function decodeEntities(text: string): string { - return text - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'|'/g, "'") - .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))); -} - -/** Collapse the whitespace HTML and quoted-printable leave behind. */ -function normalise(text: string): string { - return text - .replace(/\r\n/g, "\n") - .replace(/[ \t]+\n/g, "\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); -} - -function clip(text: string, max: number): string { - return text.length > max ? `${text.slice(0, max)}\n\n[… ${text.length - max} more characters]` : text; -} - // --------------------------------------------------------------------------- // Calendar // --------------------------------------------------------------------------- -export type CalendarAttendee = MailParticipant & { - /** accepted | declined | tentative | needsAction */ - response: string; - self: boolean; -}; - -export type CalendarEvent = { - id: string; - title: string; - description: string; - location: string; - start: Date; - end: Date; - allDay: boolean; - status: string; - organizer: MailParticipant | null; - attendees: CalendarAttendee[]; - /** A Meet or other conferencing link, when the event has one. */ - meetingUrl: string; - /** The event in Google Calendar. */ - url: string; -}; - type GcalTime = { dateTime?: string; date?: string }; type GcalEvent = { id: string; @@ -436,26 +351,27 @@ type GcalEvent = { attendees?: { email?: string; displayName?: string; responseStatus?: string; self?: boolean }[]; }; +export function googleCalendarReader(token: string): CalendarReader { + return { + listEvents(window) { + return calendarListEvents(token, window); + }, + }; +} + /** * Events on the primary calendar in a window, expanded so a weekly interview - * loop shows each occurrence. `q` is Google's free-text search over title, - * description, location and attendee addresses — used only by search_calendar, - * because matching a pipeline against it is done locally on the attendee list - * instead (one request, exact addresses, no guessing about how the search - * tokenises an email). + * loop shows each occurrence. Free text is matched here rather than through + * Google's `q`, so every provider answers the same question the same way. */ -export async function calendarListEvents( - token: string, - options: { from: Date; to: Date; query?: string; limit?: number }, -): Promise { +export async function calendarListEvents(token: string, window: CalendarWindow): Promise { const params = new URLSearchParams({ - timeMin: options.from.toISOString(), - timeMax: options.to.toISOString(), + timeMin: window.from.toISOString(), + timeMax: window.to.toISOString(), singleEvents: "true", orderBy: "startTime", - maxResults: String(Math.min(Math.max(options.limit ?? 250, 1), 2500)), + maxResults: String(Math.min(Math.max(window.limit ?? 250, 1), 2500)), }); - if (options.query) params.set("q", options.query); const out: CalendarEvent[] = []; let pageToken: string | undefined; do { @@ -466,13 +382,23 @@ export async function calendarListEvents( ); for (const item of page.items ?? []) { const event = toEvent(item); - if (event) out.push(event); + if (event && mentions(searchable(event), window.query)) out.push(event); } pageToken = page.nextPageToken; - } while (pageToken && out.length < (options.limit ?? 250)); + } while (pageToken && out.length < (window.limit ?? 250)); return out; } +function searchable(event: CalendarEvent): string { + return [ + event.title, + event.description, + event.location, + ...event.attendees.map((a) => `${a.name} ${a.email}`), + event.organizer ? `${event.organizer.name} ${event.organizer.email}` : "", + ].join(" "); +} + function toEvent(item: GcalEvent): CalendarEvent | null { if (item.status === "cancelled") return null; const allDay = Boolean(item.start?.date && !item.start?.dateTime); diff --git a/src/lib/accounts/imap.ts b/src/lib/accounts/imap.ts new file mode 100644 index 0000000..91a524b --- /dev/null +++ b/src/lib/accounts/imap.ts @@ -0,0 +1,299 @@ +import { ImapFlow, type SearchObject } from "imapflow"; +import { simpleParser } from "mailparser"; +import { + PROVIDER_TIMEOUT_MS, + ProviderError, + type MailMessage, + type MailParticipant, + type MailReader, + type MailSearch, + type MailThread, + type MailThreadSummary, +} from "@/lib/accounts/types"; +import { clip, mergeParticipants, normalise, stripHtml } from "@/lib/accounts/text"; + +/** + * Any mailbox that speaks IMAP: Fastmail, iCloud, Yahoo, a university + * account, a self-hosted server. + * + * Two libraries rather than a hand-rolled client, unlike the OAuth + * providers: IMAP is a stateful, decades-old protocol with a long tail of + * server quirks, and imapflow and mailparser are the same maintained pair + * every serious Node mail client uses. They are the largest dependencies in + * the app and are loaded only when an IMAP account is read. + * + * What a thread is, here: IMAP has no threads. Messages are joined by their + * Message-ID and In-Reply-To headers, which is what every mail client does + * under the hood. A reply whose sender's client dropped In-Reply-To lands as + * a thread of its own; that is the protocol's limit, not a bug to chase. + * + * Read-only: every mailbox is opened read-only, so even a flag cannot change. + * The app password is used to log in and for nothing else. + */ + +export type ImapConfig = { + host: string; + port: number; + username: string; + password: string; + /** The address of the mailbox, for telling "me" from everyone else. */ + accountEmail: string; +}; + +/** Where mail lives that is worth searching: what came in, and what went out. */ +type Mailboxes = { inbox: string; sent: string | null }; + +async function open(config: ImapConfig): Promise { + const client = new ImapFlow({ + host: config.host, + port: config.port, + secure: config.port === 993, + auth: { user: config.username, pass: config.password }, + logger: false, + connectionTimeout: PROVIDER_TIMEOUT_MS, + greetingTimeout: PROVIDER_TIMEOUT_MS, + socketTimeout: PROVIDER_TIMEOUT_MS * 3, + }); + try { + await client.connect(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const auth = /auth|login|credential|password/i.test(message); + throw new ProviderError( + auth + ? `${config.host} refused the login. Use an app password rather than the account password, and check the username is the full address.` + : `Could not reach ${config.host}:${config.port}. ${message}`, + { revoked: auth }, + ); + } + return client; +} + +async function mailboxes(client: ImapFlow): Promise { + const list = await client.list(); + const inbox = list.find((box) => box.path.toUpperCase() === "INBOX")?.path ?? "INBOX"; + const sent = + list.find((box) => box.specialUse === "\\Sent")?.path ?? + list.find((box) => /^sent( (items|mail|messages))?$/i.test(box.name))?.path ?? + null; + return { inbox, sent }; +} + +/** Log in, list mailboxes, log out. The whole test of an IMAP account. */ +export async function verifyImap(config: ImapConfig): Promise<{ inbox: string; sent: string | null }> { + const client = await open(config); + try { + return await mailboxes(client); + } finally { + await client.logout().catch(() => undefined); + } +} + +/** + * imapflow's `or` takes a list and refuses one entry; a single term is the + * term itself. Domains work because IMAP's FROM/TO/CC search is a substring + * match on the address. + */ +function criteria(search: MailSearch): SearchObject | null { + const terms = [...(search.addresses ?? []), ...(search.domains ?? [])]; + const alternatives: SearchObject[] = terms.flatMap((term) => [{ from: term }, { to: term }, { cc: term }]); + const base: SearchObject = {}; + if (alternatives.length === 1) Object.assign(base, alternatives[0]); + else if (alternatives.length > 1) base.or = alternatives; + if (search.text?.trim()) base.text = search.text.trim(); + if (search.newerThanDays) base.since = new Date(Date.now() - search.newerThanDays * 86_400_000); + return Object.keys(base).length ? base : null; +} + +type Found = { + mailbox: string; + uid: number; + messageId: string; + inReplyTo: string; + subject: string; + date: Date; + from: MailParticipant | null; + to: MailParticipant[]; + cc: MailParticipant[]; + unread: boolean; +}; + +type EnvelopeAddress = { name?: string; address?: string }; + +function participant(address: EnvelopeAddress | undefined): MailParticipant | null { + const email = address?.address?.trim().toLowerCase(); + return email ? { name: address?.name ?? "", email } : null; +} + +function participants(addresses: EnvelopeAddress[] | undefined): MailParticipant[] { + return (addresses ?? []).map(participant).filter((p): p is MailParticipant => p !== null); +} + +/** A thread id that survives a round trip: which mailbox, which UIDs. */ +function encodeThreadId(members: { mailbox: string; uid: number }[]): string { + return Buffer.from(JSON.stringify(members.map((m) => [m.mailbox, m.uid]))).toString("base64url"); +} + +function decodeThreadId(id: string): { mailbox: string; uid: number }[] { + try { + const raw = JSON.parse(Buffer.from(id, "base64url").toString()) as [string, number][]; + return raw.map(([mailbox, uid]) => ({ mailbox: String(mailbox), uid: Number(uid) })); + } catch { + throw new ProviderError("That is not a thread id from this account."); + } +} + +/** Join messages into threads by Message-ID and In-Reply-To. */ +function threadsOf(found: Found[]): Found[][] { + const byId = new Map(); + for (const message of found) if (message.messageId) byId.set(message.messageId, message); + + const parent = new Map(); + const root = (message: Found): Found => { + let current = message; + while (parent.get(current) && parent.get(current) !== current) current = parent.get(current)!; + return current; + }; + for (const message of found) { + const replyTo = message.inReplyTo ? byId.get(message.inReplyTo) : undefined; + if (replyTo && replyTo !== message) { + const a = root(message); + const b = root(replyTo); + if (a !== b) parent.set(a, b); + } + } + + const groups = new Map(); + for (const message of found) { + const key = root(message); + const bucket = groups.get(key); + if (bucket) bucket.push(message); + else groups.set(key, [message]); + } + return [...groups.values()].map((group) => group.sort((a, b) => a.date.getTime() - b.date.getTime())); +} + +export function imapMailReader(config: ImapConfig): MailReader { + return { + async searchThreads(search) { + const query = criteria(search); + if (!query) return []; + const client = await open(config); + try { + const boxes = await mailboxes(client); + const found: Found[] = []; + for (const path of [boxes.inbox, boxes.sent].filter((p): p is string => Boolean(p))) { + const lock = await client.getMailboxLock(path, { readOnly: true }); + try { + const uids = ((await client.search(query, { uid: true })) || []) as number[]; + // Newest first, and a bound: a recruiter domain that matches a + // year of newsletters must not fetch a year of envelopes. + const recent = uids.sort((a, b) => b - a).slice(0, 200); + if (recent.length === 0) continue; + const messages = await client.fetchAll( + recent, + { uid: true, envelope: true, flags: true, internalDate: true }, + { uid: true }, + ); + for (const message of messages) { + const envelope = message.envelope; + if (!envelope) continue; + const date = message.internalDate ? new Date(message.internalDate) : (envelope.date ?? new Date(0)); + found.push({ + mailbox: path, + uid: message.uid, + messageId: envelope.messageId ?? "", + inReplyTo: envelope.inReplyTo ?? "", + subject: envelope.subject ?? "", + date: date instanceof Date ? date : new Date(date), + from: participant(envelope.from?.[0]), + to: participants(envelope.to), + cc: participants(envelope.cc), + unread: !(message.flags?.has("\\Seen") ?? false), + }); + } + } finally { + lock.release(); + } + } + + return threadsOf(found) + .map((thread): MailThreadSummary => { + const first = thread[0]; + const last = thread[thread.length - 1]; + return { + id: encodeThreadId(thread), + subject: first.subject || last.subject || "(no subject)", + snippet: "", + participants: mergeParticipants( + thread.flatMap((m) => [m.from ? [m.from] : [], m.to, m.cc]), + ), + lastFrom: last.from, + firstMessageAt: first.date, + lastMessageAt: last.date, + messageCount: thread.length, + unread: thread.some((m) => m.unread), + url: "", + }; + }) + .sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime()) + .slice(0, search.limit ?? 20); + } finally { + await client.logout().catch(() => undefined); + } + }, + + async getThread(threadId) { + const members = decodeThreadId(threadId).slice(0, 20); + const client = await open(config); + try { + const messages: MailMessage[] = []; + const byMailbox = new Map(); + for (const member of members) { + const bucket = byMailbox.get(member.mailbox); + if (bucket) bucket.push(member.uid); + else byMailbox.set(member.mailbox, [member.uid]); + } + for (const [path, uids] of byMailbox) { + const lock = await client.getMailboxLock(path, { readOnly: true }); + try { + for (const uid of uids) { + const fetched = await client.fetchOne(String(uid), { uid: true, source: true }, { uid: true }); + if (!fetched || !fetched.source) continue; + const parsed = await simpleParser(fetched.source, { skipImageLinks: true, skipTextLinks: true }); + const from = parsed.from?.value?.[0]; + const addresses = (value: typeof parsed.to) => + (Array.isArray(value) ? value : value ? [value] : []) + .flatMap((list) => list.value) + .map((a) => participant({ name: a.name, address: a.address })) + .filter((p): p is MailParticipant => p !== null); + messages.push({ + id: `${path}:${uid}`, + from: participant({ name: from?.name, address: from?.address }), + to: addresses(parsed.to), + cc: addresses(parsed.cc), + date: parsed.date ?? new Date(0), + subject: parsed.subject ?? "", + body: clip( + normalise(parsed.text || (parsed.html ? stripHtml(String(parsed.html)) : "")), + 6000, + ), + }); + } + } finally { + lock.release(); + } + } + messages.sort((a, b) => a.date.getTime() - b.date.getTime()); + return { + id: threadId, + subject: messages[0]?.subject || "(no subject)", + url: "", + messages, + } satisfies MailThread; + } finally { + await client.logout().catch(() => undefined); + } + }, + }; +} diff --git a/src/lib/accounts/index.ts b/src/lib/accounts/index.ts new file mode 100644 index 0000000..92a26a6 --- /dev/null +++ b/src/lib/accounts/index.ts @@ -0,0 +1,78 @@ +import type { AccountProvider } from "@prisma/client"; +import type { AccountFeature, CalendarReader, MailReader } from "@/lib/accounts/types"; +import { googleCalendarReader, googleMailReader } from "@/lib/accounts/google"; +import { microsoftCalendarReader, microsoftMailReader } from "@/lib/accounts/microsoft"; +import { imapMailReader } from "@/lib/accounts/imap"; +import { caldavCalendarReader } from "@/lib/accounts/caldav"; + +/** + * From a row's credentials to a reader. The data layer resolves a live + * access token for the OAuth providers before calling this; the IMAP kind + * carries everything it needs on the row. + */ + +export type ReaderCredentials = { + provider: AccountProvider; + email: string; + features: string[]; + /** A live access token, for GOOGLE and MICROSOFT. */ + accessToken: string; + imapHost: string; + imapPort: number; + imapUsername: string; + imapPassword: string; + caldavUrl: string; + caldavUsername: string; + caldavPassword: string; +}; + +export function hasFeature(account: { features: string[] }, feature: AccountFeature): boolean { + return account.features.includes(feature); +} + +export function mailReaderFor(account: ReaderCredentials): MailReader | null { + if (!hasFeature(account, "mail")) return null; + switch (account.provider) { + case "GOOGLE": + return googleMailReader(account.accessToken, account.email); + case "MICROSOFT": + return microsoftMailReader(account.accessToken); + case "IMAP": + return imapMailReader({ + host: account.imapHost, + port: account.imapPort, + username: account.imapUsername, + password: account.imapPassword, + accountEmail: account.email, + }); + } +} + +export function calendarReaderFor(account: ReaderCredentials): CalendarReader | null { + if (!hasFeature(account, "calendar")) return null; + switch (account.provider) { + case "GOOGLE": + return googleCalendarReader(account.accessToken); + case "MICROSOFT": + return microsoftCalendarReader(account.accessToken, account.email); + case "IMAP": + return caldavCalendarReader({ + url: account.caldavUrl, + username: account.caldavUsername, + password: account.caldavPassword, + }); + } +} + +/** The name a person knows the provider by. */ +export const PROVIDER_LABEL: Record = { + GOOGLE: "Google", + MICROSOFT: "Microsoft 365", + IMAP: "IMAP & CalDAV", +}; + +/** What each half is called for a given provider, on a tile or in a sentence. */ +export function featureLabel(provider: AccountProvider, feature: AccountFeature): string { + if (feature === "mail") return provider === "GOOGLE" ? "Gmail" : provider === "MICROSOFT" ? "Outlook" : "IMAP"; + return provider === "IMAP" ? "CalDAV" : "Calendar"; +} diff --git a/src/lib/accounts/microsoft.ts b/src/lib/accounts/microsoft.ts new file mode 100644 index 0000000..c1956ff --- /dev/null +++ b/src/lib/accounts/microsoft.ts @@ -0,0 +1,419 @@ +import { + PROVIDER_TIMEOUT_MS, + ProviderError, + type AccountFeature, + type CalendarEvent, + type CalendarReader, + type CalendarWindow, + type MailMessage, + type MailParticipant, + type MailReader, + type MailSearch, + type MailThread, + type MailThreadSummary, +} from "@/lib/accounts/types"; +import { clip, findMeetingLink, mentions, mergeParticipants, normalise, stripHtml } from "@/lib/accounts/text"; + +/** + * Microsoft 365 — Outlook mail and calendar — through Microsoft Graph. + * + * Same shape as google.ts: OAuth against the common tenant so a personal + * outlook.com and a work account both work, a refresh token kept by the data + * layer, and read-only scopes. `Mail.Read` and `Calendars.Read` cannot send, + * accept, move or delete anything, and nothing here issues anything but GET + * apart from the token endpoint. + * + * Graph groups messages by `conversationId`, which is what a thread is here. + * A search returns messages, not conversations, so a thread listed from a + * search knows about the messages the search matched — a long thread where + * only the last reply mentions the recruiter is still found, and get_thread + * fills in the rest. + */ + +const AUTH_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; +const TOKEN_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; +const GRAPH = "https://graph.microsoft.com/v1.0"; + +/** The signed state cookie for the consent round trip. Same shape as Google's. */ +export const MICROSOFT_STATE_COOKIE = "hired_microsoft_oauth"; + +/** Where Microsoft sends the browser back to. Registered on the app registration. */ +export function microsoftRedirectUri(baseUrl: string) { + return `${baseUrl.replace(/\/$/, "")}/api/auth/microsoft/callback`; +} + +export const MICROSOFT_SCOPES: Record = { + mail: "Mail.Read", + calendar: "Calendars.Read", +}; + +/** Always asked for alongside the two above: who the account is, and a refresh token. */ +const BASE_SCOPES = ["openid", "email", "profile", "offline_access", "User.Read"]; + +export function microsoftFeatures(scopes: string[]): AccountFeature[] { + // Graph hands scopes back as bare names or as full URIs, depending on the + // tenant. Match on the tail. + const tails = scopes.map((scope) => scope.split("/").pop() ?? scope); + return (Object.keys(MICROSOFT_SCOPES) as AccountFeature[]).filter((feature) => + tails.includes(MICROSOFT_SCOPES[feature]), + ); +} + +export function microsoftAuthUrl(input: { + clientId: string; + redirectUri: string; + state: string; + nonce: string; +}) { + const params = new URLSearchParams({ + client_id: input.clientId, + redirect_uri: input.redirectUri, + response_type: "code", + response_mode: "query", + scope: [...BASE_SCOPES, MICROSOFT_SCOPES.mail, MICROSOFT_SCOPES.calendar].join(" "), + state: input.state, + nonce: input.nonce, + prompt: "select_account", + }); + return `${AUTH_ENDPOINT}?${params.toString()}`; +} + +export type MicrosoftGrant = { + accessToken: string; + refreshToken: string; + expiresAt: Date; + scopes: string[]; +}; + +async function tokenRequest(body: Record): Promise { + const response = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body), + signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS), + }); + const json = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) { + const code = String(json.error ?? ""); + const detail = [json.error, json.error_description].filter(Boolean).join(": "); + throw new ProviderError(detail || `Microsoft refused the token request (HTTP ${response.status}).`, { + revoked: code === "invalid_grant" || code === "interaction_required", + status: response.status, + }); + } + const accessToken = typeof json.access_token === "string" ? json.access_token : ""; + if (!accessToken) throw new ProviderError("Microsoft did not return an access token."); + const seconds = typeof json.expires_in === "number" ? json.expires_in : 3600; + return { + accessToken, + refreshToken: typeof json.refresh_token === "string" ? json.refresh_token : "", + expiresAt: new Date(Date.now() + seconds * 1000), + scopes: typeof json.scope === "string" ? json.scope.split(/\s+/).filter(Boolean) : [], + }; +} + +export function exchangeMicrosoftCode(input: { + clientId: string; + clientSecret: string; + code: string; + redirectUri: string; +}) { + return tokenRequest({ + client_id: input.clientId, + client_secret: input.clientSecret, + code: input.code, + redirect_uri: input.redirectUri, + grant_type: "authorization_code", + }); +} + +/** + * Microsoft rotates refresh tokens: the response may carry a new one, and + * the data layer stores it when it does. + */ +export function refreshMicrosoftToken(input: { clientId: string; clientSecret: string; refreshToken: string }) { + return tokenRequest({ + client_id: input.clientId, + client_secret: input.clientSecret, + refresh_token: input.refreshToken, + grant_type: "refresh_token", + scope: [...BASE_SCOPES, MICROSOFT_SCOPES.mail, MICROSOFT_SCOPES.calendar].join(" "), + }); +} + +async function get(token: string, url: string, headers: Record = {}): Promise { + const response = await fetch(url, { + headers: { authorization: `Bearer ${token}`, ...headers }, + signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS), + }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { error?: { code?: string; message?: string } }; + const message = body.error?.message || `Microsoft answered HTTP ${response.status}.`; + throw new ProviderError(message, { + status: response.status, + revoked: response.status === 401, + }); + } + return (await response.json()) as T; +} + +/** Who the token belongs to. `mail` is empty on some personal accounts; the UPN is the address then. */ +export async function microsoftProfile(token: string): Promise<{ id: string; email: string; name: string }> { + const me = await get<{ id?: string; mail?: string; userPrincipalName?: string; displayName?: string }>( + token, + `${GRAPH}/me?$select=id,mail,userPrincipalName,displayName`, + ); + const email = (me.mail || me.userPrincipalName || "").trim().toLowerCase(); + if (!email) throw new ProviderError("Microsoft did not say which account this is."); + return { id: me.id ?? "", email, name: me.displayName ?? "" }; +} + +// --------------------------------------------------------------------------- +// Mail +// --------------------------------------------------------------------------- + +type GraphRecipient = { emailAddress?: { name?: string; address?: string } }; +type GraphMessage = { + id: string; + conversationId?: string; + subject?: string; + bodyPreview?: string; + body?: { contentType?: string; content?: string }; + from?: GraphRecipient; + toRecipients?: GraphRecipient[]; + ccRecipients?: GraphRecipient[]; + receivedDateTime?: string; + sentDateTime?: string; + isRead?: boolean; + webLink?: string; +}; + +const MESSAGE_FIELDS = + "id,conversationId,subject,bodyPreview,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,isRead,webLink"; + +function person(recipient: GraphRecipient | undefined): MailParticipant | null { + const address = recipient?.emailAddress?.address?.trim().toLowerCase(); + if (!address) return null; + return { name: recipient?.emailAddress?.name ?? "", email: address }; +} + +function people(recipients: GraphRecipient[] | undefined): MailParticipant[] { + return (recipients ?? []).map(person).filter((p): p is MailParticipant => p !== null); +} + +function messageDate(message: GraphMessage): Date { + const parsed = new Date(message.receivedDateTime ?? message.sentDateTime ?? ""); + return Number.isNaN(parsed.getTime()) ? new Date(0) : parsed; +} + +/** + * The KQL for a search. `participants:` covers from, to and cc at once, and + * matches on a domain as well as an address. Free text is added as words. + */ +export function graphSearchFor(search: MailSearch): string { + const terms = [...(search.addresses ?? []), ...(search.domains ?? [])].map( + (term) => `participants:${term}`, + ); + const parts = [terms.length ? `(${terms.join(" OR ")})` : ""]; + if (search.text?.trim()) parts.push(search.text.trim().replace(/"/g, "")); + if (search.newerThanDays) { + const since = new Date(Date.now() - search.newerThanDays * 86_400_000).toISOString().slice(0, 10); + parts.push(`received>=${since}`); + } + return parts.filter(Boolean).join(" AND "); +} + +export function microsoftMailReader(token: string): MailReader { + return { + async searchThreads(search) { + const kql = graphSearchFor(search); + if (!kql) return []; + const params = new URLSearchParams({ + $search: `"${kql}"`, + $select: MESSAGE_FIELDS, + $top: String(Math.min(Math.max((search.limit ?? 20) * 4, 25), 250)), + }); + const page = await get<{ value?: GraphMessage[] }>(token, `${GRAPH}/me/messages?${params.toString()}`); + const byConversation = new Map(); + for (const message of page.value ?? []) { + const key = message.conversationId ?? message.id; + const bucket = byConversation.get(key); + if (bucket) bucket.push(message); + else byConversation.set(key, [message]); + } + return [...byConversation.entries()] + .map(([id, messages]) => summarise(id, messages)) + .sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime()) + .slice(0, search.limit ?? 20); + }, + + async getThread(conversationId) { + // No $orderby alongside this $filter: Graph rejects the pair as an + // inefficient query. Sorted here instead. + const params = new URLSearchParams({ + $filter: `conversationId eq '${conversationId.replace(/'/g, "''")}'`, + $select: `${MESSAGE_FIELDS},body`, + $top: "50", + }); + const page = await get<{ value?: GraphMessage[] }>(token, `${GRAPH}/me/messages?${params.toString()}`, { + Prefer: 'outlook.body-content-type="text"', + }); + const messages: MailMessage[] = (page.value ?? []) + .sort((a, b) => messageDate(a).getTime() - messageDate(b).getTime()) + .map((message) => ({ + id: message.id, + from: person(message.from), + to: people(message.toRecipients), + cc: people(message.ccRecipients), + date: messageDate(message), + subject: message.subject ?? "", + body: clip( + normalise( + message.body?.contentType?.toLowerCase() === "html" + ? stripHtml(message.body.content ?? "") + : (message.body?.content ?? ""), + ), + 6000, + ), + })); + return { + id: conversationId, + subject: messages[0]?.subject || "(no subject)", + url: page.value?.[0]?.webLink ?? "", + messages, + }; + }, + }; +} + +function summarise(conversationId: string, found: GraphMessage[]): MailThreadSummary { + const messages = found.slice().sort((a, b) => messageDate(a).getTime() - messageDate(b).getTime()); + const first = messages[0]; + const last = messages[messages.length - 1]; + return { + id: conversationId, + subject: first.subject?.trim() || last.subject?.trim() || "(no subject)", + snippet: normalise(last.bodyPreview ?? ""), + participants: mergeParticipants( + messages.flatMap((message) => [ + person(message.from) ? [person(message.from)!] : [], + people(message.toRecipients), + people(message.ccRecipients), + ]), + ), + lastFrom: person(last.from), + firstMessageAt: messageDate(first), + lastMessageAt: messageDate(last), + messageCount: messages.length, + unread: messages.some((message) => message.isRead === false), + url: last.webLink ?? "", + }; +} + +// --------------------------------------------------------------------------- +// Calendar +// --------------------------------------------------------------------------- + +type GraphTime = { dateTime?: string; timeZone?: string }; +type GraphEvent = { + id: string; + subject?: string; + bodyPreview?: string; + isAllDay?: boolean; + isCancelled?: boolean; + start?: GraphTime; + end?: GraphTime; + location?: { displayName?: string }; + organizer?: GraphRecipient; + attendees?: { emailAddress?: { name?: string; address?: string }; status?: { response?: string } }[]; + onlineMeeting?: { joinUrl?: string }; + onlineMeetingUrl?: string; + webLink?: string; + responseStatus?: { response?: string }; +}; + +/** Graph hands times back in the zone asked for, without an offset. UTC is asked for. */ +function graphDate(time: GraphTime | undefined): Date { + const raw = time?.dateTime ?? ""; + if (!raw) return new Date(NaN); + return new Date(raw.endsWith("Z") ? raw : `${raw.replace(/\.\d+$/, "")}Z`); +} + +const RESPONSES: Record = { + accepted: "accepted", + declined: "declined", + tentativelyaccepted: "tentative", + organizer: "accepted", + notresponded: "needsAction", + none: "needsAction", +}; + +export function microsoftCalendarReader(token: string, accountEmail: string): CalendarReader { + return { + async listEvents(window: CalendarWindow) { + const out: CalendarEvent[] = []; + let url: string | undefined = + `${GRAPH}/me/calendarView?` + + new URLSearchParams({ + startDateTime: window.from.toISOString(), + endDateTime: window.to.toISOString(), + $top: "100", + $select: + "id,subject,bodyPreview,isAllDay,isCancelled,start,end,location,organizer,attendees,onlineMeeting,onlineMeetingUrl,webLink,responseStatus", + }).toString(); + while (url && out.length < (window.limit ?? 250)) { + const page: { value?: GraphEvent[]; "@odata.nextLink"?: string } = await get(token, url, { + Prefer: 'outlook.timezone="UTC"', + }); + for (const item of page.value ?? []) { + const event = toEvent(item, accountEmail); + if (event && mentions(searchable(event), window.query)) out.push(event); + } + url = page["@odata.nextLink"]; + } + return out; + }, + }; +} + +function searchable(event: CalendarEvent): string { + return [ + event.title, + event.description, + event.location, + ...event.attendees.map((a) => `${a.name} ${a.email}`), + event.organizer ? `${event.organizer.name} ${event.organizer.email}` : "", + ].join(" "); +} + +function toEvent(item: GraphEvent, accountEmail: string): CalendarEvent | null { + if (item.isCancelled) return null; + const start = graphDate(item.start); + const end = graphDate(item.end); + if (Number.isNaN(start.getTime())) return null; + const description = normalise(item.bodyPreview ?? ""); + return { + id: item.id, + title: item.subject?.trim() || "(no title)", + description: clip(description, 2000), + location: item.location?.displayName ?? "", + start, + end: Number.isNaN(end.getTime()) ? start : end, + allDay: item.isAllDay === true, + status: "confirmed", + organizer: person(item.organizer), + attendees: (item.attendees ?? []) + .filter((attendee) => attendee.emailAddress?.address) + .map((attendee) => { + const email = attendee.emailAddress!.address!.trim().toLowerCase(); + return { + name: attendee.emailAddress?.name ?? "", + email, + response: RESPONSES[(attendee.status?.response ?? "none").toLowerCase()] ?? "needsAction", + self: email === accountEmail, + }; + }), + meetingUrl: item.onlineMeeting?.joinUrl || item.onlineMeetingUrl || findMeetingLink(description), + url: item.webLink ?? "", + }; +} diff --git a/src/lib/accounts/net.ts b/src/lib/accounts/net.ts new file mode 100644 index 0000000..3fe21f9 --- /dev/null +++ b/src/lib/accounts/net.ts @@ -0,0 +1,72 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import { ProviderError } from "@/lib/accounts/types"; + +/** + * Where an IMAP or CalDAV connection may be pointed. + * + * An account form that takes a hostname is a request the server makes on + * the person's behalf, to wherever they say. That must not be the host's + * own loopback, its link-local range — where cloud metadata services live — + * or an unspecified or multicast address. Private ranges are allowed on + * purpose: a mail server on somebody's home LAN is a real reason to run this + * app yourself, and the loopback and metadata cases are where the damage is. + * + * Checked when an account is connected, against every address the name + * resolves to, so a name that quietly points at 127.0.0.1 is refused with + * the same sentence as the literal. + */ + +function blocked(address: string): boolean { + const v4 = address.startsWith("::ffff:") ? address.slice(7) : address; + if (isIP(v4) === 4) { + const [a, b] = v4.split(".").map(Number); + return ( + a === 127 || // loopback + a === 0 || // unspecified + (a === 169 && b === 254) || // link-local, including 169.254.169.254 + a >= 224 // multicast and reserved + ); + } + const lower = address.toLowerCase(); + return ( + lower === "::" || + lower === "::1" || + lower.startsWith("fe80:") || // link-local + lower.startsWith("ff") // multicast + ); +} + +/** Throws a legible error when a host must not be connected to. */ +export async function assertReachableHost(hostname: string): Promise { + const host = hostname.trim().replace(/^\[|\]$/g, "").toLowerCase(); + if (!host) throw new ProviderError("Give a server to connect to."); + if (host === "localhost" || host.endsWith(".localhost") || host === "metadata.google.internal") { + throw new ProviderError(`${hostname} is this server itself, which is not a mail or calendar host.`); + } + const addresses = isIP(host) + ? [host] + : await lookup(host, { all: true }) + .then((found) => found.map((entry) => entry.address)) + .catch(() => { + throw new ProviderError(`${hostname} does not resolve. Check the spelling.`); + }); + if (addresses.length === 0) throw new ProviderError(`${hostname} does not resolve. Check the spelling.`); + if (addresses.some(blocked)) { + throw new ProviderError(`${hostname} points at this server's own network, which is not a mail or calendar host.`); + } +} + +/** The host of a CalDAV URL, refusing anything that is not http(s). */ +export function caldavHost(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new ProviderError("The CalDAV address has to be a full URL, starting with https://."); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new ProviderError("The CalDAV address has to start with https:// or http://."); + } + return parsed.hostname; +} diff --git a/src/lib/accounts/text.ts b/src/lib/accounts/text.ts new file mode 100644 index 0000000..001c1e4 --- /dev/null +++ b/src/lib/accounts/text.ts @@ -0,0 +1,97 @@ +import type { MailParticipant } from "@/lib/accounts/types"; + +/** + * The text handling every provider needs and none should own: parsing an + * address header, turning HTML mail into something readable, trimming what + * would flood a tool result. + */ + +/** "Jane Doe , bob@acme.com" → two participants. */ +export function parseAddresses(header: string): MailParticipant[] { + const out: MailParticipant[] = []; + // Split on commas that are not inside quotes. Display names may carry one. + const parts = header.match(/(?:"[^"]*"|[^,])+/g) ?? []; + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) continue; + const angle = /^(.*?)<([^>]+)>\s*$/.exec(trimmed); + if (angle) { + out.push({ + name: angle[1].trim().replace(/^"|"$/g, "").trim(), + email: angle[2].trim().toLowerCase(), + }); + } else if (trimmed.includes("@")) { + out.push({ name: "", email: trimmed.replace(/^"|"$/g, "").toLowerCase() }); + } + } + return out; +} + +export function domainOf(email: string): string { + return email.split("@")[1]?.toLowerCase() ?? ""; +} + +/** Everyone across a set of address lists, deduplicated, keeping the first name seen. */ +export function mergeParticipants(lists: MailParticipant[][]): MailParticipant[] { + const seen = new Map(); + for (const list of lists) { + for (const person of list) { + if (!person.email) continue; + const existing = seen.get(person.email); + if (!existing) seen.set(person.email, { ...person }); + else if (!existing.name && person.name) existing.name = person.name; + } + } + return [...seen.values()]; +} + +export function stripHtml(html: string): string { + return decodeEntities( + html + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "\n") + .replace(/<\/(p|div|tr|li|h[1-6]|blockquote)>/gi, "\n") + .replace(/<[^>]+>/g, ""), + ); +} + +export function decodeEntities(text: string): string { + return text + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))); +} + +/** Collapse the whitespace HTML and quoted-printable leave behind. */ +export function normalise(text: string): string { + return text + .replace(/\r\n/g, "\n") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function clip(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max)}\n\n[… ${text.length - max} more characters]` : text; +} + +/** The first Meet, Teams, Zoom or Webex link in a blob of text, or nothing. */ +export function findMeetingLink(text: string): string { + const match = /https?:\/\/(?:meet\.google\.com|teams\.microsoft\.com|[\w.-]*zoom\.us|[\w.-]*webex\.com)\/[^\s<>"')]+/i.exec( + text, + ); + return match?.[0] ?? ""; +} + +/** Whether `text` mentions any of the words, case-insensitively. Empty query matches. */ +export function mentions(text: string, query: string | undefined): boolean { + const words = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean); + if (words.length === 0) return true; + const haystack = text.toLowerCase(); + return words.every((word) => haystack.includes(word)); +} diff --git a/src/lib/accounts/types.ts b/src/lib/accounts/types.ts new file mode 100644 index 0000000..3c27b54 --- /dev/null +++ b/src/lib/accounts/types.ts @@ -0,0 +1,120 @@ +/** + * What every mail and calendar provider has to produce. + * + * Gmail, Microsoft Graph and IMAP+CalDAV all speak a different wire protocol, + * and none of that reaches the rest of the app: a screen or a tool asks a + * `MailReader` for threads and a `CalendarReader` for events, and gets these + * shapes back whichever account answered. The data layer (src/lib/data/ + * accounts.ts) is what picks the provider, holds the credentials and merges + * across a person's accounts; nothing in this directory touches the database. + * + * Read-only by construction. There is no interface for sending, filing or + * deleting, so no provider can grow one by accident. + */ + +/** The two things an account can provide. Stored on the row as strings. */ +export type AccountFeature = "mail" | "calendar"; + +export type MailParticipant = { name: string; email: string }; + +export type MailThreadSummary = { + /** Opaque to callers. The data layer prefixes it with the account id. */ + id: string; + subject: string; + /** The provider's one-line preview of the latest message. */ + snippet: string; + /** Everyone on the thread, the account holder included, deduplicated by address. */ + participants: MailParticipant[]; + /** Who sent the most recent message. */ + lastFrom: MailParticipant | null; + firstMessageAt: Date; + lastMessageAt: Date; + messageCount: number; + unread: boolean; + /** Where to open it in the provider's own client. Empty when there is nowhere. */ + url: string; +}; + +export type MailMessage = { + id: string; + from: MailParticipant | null; + to: MailParticipant[]; + cc: MailParticipant[]; + date: Date; + subject: string; + /** Plain text. HTML-only messages are stripped to text; long bodies are cut. */ + body: string; +}; + +export type MailThread = { + id: string; + subject: string; + url: string; + messages: MailMessage[]; +}; + +/** + * What to look for. Addresses and domains are matched on from, to and cc; + * `text` is the provider's own free-text search — Gmail's operators pass + * straight through, everything else treats it as words. + */ +export type MailSearch = { + addresses?: string[]; + domains?: string[]; + text?: string; + newerThanDays?: number; + limit?: number; +}; + +export type CalendarAttendee = MailParticipant & { + /** accepted | declined | tentative | needsAction */ + response: string; + self: boolean; +}; + +export type CalendarEvent = { + id: string; + title: string; + description: string; + location: string; + start: Date; + end: Date; + allDay: boolean; + status: string; + organizer: MailParticipant | null; + attendees: CalendarAttendee[]; + /** A Meet, Teams or Zoom link, when the event has one. */ + meetingUrl: string; + /** The event in the provider's own calendar. Empty when there is nowhere. */ + url: string; +}; + +export type CalendarWindow = { from: Date; to: Date; query?: string; limit?: number }; + +export interface MailReader { + searchThreads(search: MailSearch): Promise; + getThread(threadId: string): Promise; +} + +export interface CalendarReader { + listEvents(window: CalendarWindow): Promise; +} + +/** + * A provider refused. `revoked` is the one distinction the data layer acts + * on: the credential is dead (a revoked token, a changed app password) and + * the person has to reconnect, as opposed to a request that merely failed. + */ +export class ProviderError extends Error { + revoked: boolean; + status: number; + constructor(message: string, options: { revoked?: boolean; status?: number } = {}) { + super(message); + this.name = "ProviderError"; + this.revoked = options.revoked ?? false; + this.status = options.status ?? 0; + } +} + +/** Every request to a provider has a bound; a hung call must not hang a page. */ +export const PROVIDER_TIMEOUT_MS = 20_000; diff --git a/src/lib/data/accounts.ts b/src/lib/data/accounts.ts new file mode 100644 index 0000000..e9a0bcf --- /dev/null +++ b/src/lib/data/accounts.ts @@ -0,0 +1,881 @@ +import type { AccountProvider, LinkedAccount } from "@prisma/client"; +import { db } from "@/lib/db"; +import { getSettings } from "@/lib/settings"; +import { + ProviderError, + type AccountFeature, + type CalendarEvent, + type MailThread, + type MailThreadSummary, +} from "@/lib/accounts/types"; +import { domainOf } from "@/lib/accounts/text"; +import { refreshAccessToken, revokeToken } from "@/lib/accounts/google"; +import { refreshMicrosoftToken } from "@/lib/accounts/microsoft"; +import { verifyImap } from "@/lib/accounts/imap"; +import { verifyCaldav } from "@/lib/accounts/caldav"; +import { assertReachableHost, caldavHost } from "@/lib/accounts/net"; +import { + PROVIDER_LABEL, + calendarReaderFor, + mailReaderFor, + type ReaderCredentials, +} from "@/lib/accounts"; + +/** + * A person's own mail and calendar, read live on their behalf, across every + * account they have connected: Google, Microsoft 365, or anything that + * speaks IMAP and CalDAV. + * + * Like every file here: userId is the first argument of every function and + * every query filters on it. The credentials that let this instance read an + * inbox are the most sensitive thing it holds about anyone, and they never + * leave this file — callers get what the provider said, never the token or + * password that asked. + * + * Nothing a provider returns is written to the database. A thread list or a + * meeting is fetched when a screen or a tool asks and shown as it came back, + * so there is no copy of anyone's mail to leak, to go stale, or to be + * subpoenaed from a server that only ever needed to *look*. The cost is a + * round trip on every open, which is why the screens load these panels after + * the page rather than blocking on them. + * + * Several accounts merge: a thread list is every account's threads sorted + * together, and one account failing — a revoked token, a server down — is a + * warning on the result, never a reason to hide the others. + */ + +const DAY = 24 * 60 * 60 * 1000; + +/** Domains that say nothing about who somebody works for. Never matched as a company. */ +const FREEMAIL = new Set([ + "gmail.com", + "googlemail.com", + "outlook.com", + "hotmail.com", + "live.com", + "yahoo.com", + "icloud.com", + "me.com", + "proton.me", + "protonmail.com", + "aol.com", + "hey.com", + "fastmail.com", +]); + +export class AccountNotConnectedError extends Error { + constructor(message: string) { + super(message); + this.name = "AccountNotConnectedError"; + } +} + +const CONNECT_HINT = "Connect one under Settings → Connections in the app."; + +// --------------------------------------------------------------------------- +// The accounts +// --------------------------------------------------------------------------- + +/** An account without its secrets — safe for a tool result and for the browser. */ +export type LinkedAccountView = { + id: string; + provider: AccountProvider; + providerLabel: string; + email: string; + label: string; + mail: boolean; + calendar: boolean; + /** For IMAP accounts: where mail and calendar are read from, without the passwords. */ + imapHost: string; + caldavUrl: string; + connectedAt: Date; + lastUsedAt: Date | null; + /** Non-empty when the last read failed and a reconnect is needed. */ + lastError: string; + lastErrorAt: Date | null; +}; + +function toView(row: LinkedAccount): LinkedAccountView { + return { + id: row.id, + provider: row.provider, + providerLabel: PROVIDER_LABEL[row.provider], + email: row.email, + label: row.label, + mail: row.features.includes("mail"), + calendar: row.features.includes("calendar"), + imapHost: row.imapHost, + caldavUrl: row.caldavUrl, + connectedAt: row.createdAt, + lastUsedAt: row.lastUsedAt, + lastError: row.lastError, + lastErrorAt: row.lastErrorAt, + }; +} + +export async function listLinkedAccounts(userId: string): Promise { + const rows = await db.linkedAccount.findMany({ where: { userId }, orderBy: { createdAt: "asc" } }); + return rows.map(toView); +} + +export type AccountAccess = { mail: boolean; calendar: boolean }; + +/** + * What the pages need to know before they draw a card: is there anything to + * read at all, and which halves. Null when nothing is connected. + */ +export async function accountAccess(userId: string): Promise { + const rows = await db.linkedAccount.findMany({ where: { userId }, select: { features: true } }); + if (rows.length === 0) return null; + return { + mail: rows.some((row) => row.features.includes("mail")), + calendar: rows.some((row) => row.features.includes("calendar")), + }; +} + +export type OAuthConnectInput = { + provider: "GOOGLE" | "MICROSOFT"; + email: string; + externalId: string; + features: AccountFeature[]; + refreshToken: string; + accessToken: string; + expiresAt: Date; +}; + +/** + * Store what a consent screen handed back. One row per provider and + * address: connecting the same account again replaces its tokens, which is + * how a person recovers from a revoked one. + */ +export async function connectOAuthAccount(userId: string, input: OAuthConnectInput) { + if (input.features.length === 0) throw new Error("Neither mail nor calendar was granted."); + if (!input.refreshToken) throw new Error("The provider did not return a refresh token."); + const email = input.email.trim().toLowerCase(); + + const previous = await db.linkedAccount.findUnique({ + where: { userId_provider_email: { userId, provider: input.provider, email } }, + }); + if (previous && previous.provider === "GOOGLE" && previous.refreshToken !== input.refreshToken) { + await revokeToken(previous.refreshToken); + } + + const data = { + externalId: input.externalId, + features: input.features, + refreshToken: input.refreshToken, + accessToken: input.accessToken, + accessTokenExpiresAt: input.expiresAt, + lastError: "", + lastErrorAt: null, + }; + const row = await db.linkedAccount.upsert({ + where: { userId_provider_email: { userId, provider: input.provider, email } }, + create: { userId, provider: input.provider, email, ...data }, + update: data, + }); + return toView(row); +} + +export type ImapConnectInput = { + email: string; + label?: string; + imapHost?: string; + imapPort?: number; + imapUsername?: string; + imapPassword?: string; + caldavUrl?: string; + caldavUsername?: string; + caldavPassword?: string; +}; + +/** + * Connect a mailbox by IMAP and a calendar by CalDAV. Either half may be + * left out. Both are tried before anything is saved, so a wrong app password + * is an error now rather than a broken tile later. Connecting the same + * address again replaces what was stored. + */ +export async function connectImapAccount(userId: string, input: ImapConnectInput) { + const email = input.email.trim().toLowerCase(); + if (!email.includes("@")) throw new Error("Give the address of the mailbox."); + + const imapHost = (input.imapHost ?? "").trim(); + const caldavUrl = (input.caldavUrl ?? "").trim(); + if (!imapHost && !caldavUrl) throw new Error("Give an IMAP server, a CalDAV URL, or both."); + + const imapUsername = (input.imapUsername ?? "").trim() || email; + const imapPassword = input.imapPassword ?? ""; + const imapPort = input.imapPort && input.imapPort > 0 ? Math.round(input.imapPort) : 993; + const caldavUsername = (input.caldavUsername ?? "").trim() || imapUsername; + const caldavPassword = input.caldavPassword || imapPassword; + + const features: AccountFeature[] = []; + if (imapHost) { + if (!imapPassword) throw new Error("The IMAP server needs a password — an app password, not the account one."); + await assertReachableHost(imapHost); + await verifyImap({ host: imapHost, port: imapPort, username: imapUsername, password: imapPassword, accountEmail: email }); + features.push("mail"); + } + if (caldavUrl) { + if (!caldavPassword) throw new Error("The CalDAV server needs a password — an app password, not the account one."); + await assertReachableHost(caldavHost(caldavUrl)); + await verifyCaldav({ url: caldavUrl, username: caldavUsername, password: caldavPassword }); + features.push("calendar"); + } + + const data = { + label: (input.label ?? "").trim().slice(0, 60), + features, + imapHost, + imapPort, + imapUsername: imapHost ? imapUsername : "", + imapPassword: imapHost ? imapPassword : "", + caldavUrl, + caldavUsername: caldavUrl ? caldavUsername : "", + caldavPassword: caldavUrl ? caldavPassword : "", + lastError: "", + lastErrorAt: null, + }; + const row = await db.linkedAccount.upsert({ + where: { userId_provider_email: { userId, provider: "IMAP", email } }, + create: { userId, provider: "IMAP", email, ...data }, + update: data, + }); + return toView(row); +} + +export async function renameLinkedAccount(userId: string, accountId: string, label: string) { + const row = await db.linkedAccount.findFirst({ where: { id: accountId, userId } }); + if (!row) throw new Error(`No connected account with id ${accountId}`); + return toView( + await db.linkedAccount.update({ where: { id: row.id }, data: { label: label.trim().slice(0, 60) } }), + ); +} + +/** Revoke where the provider allows it, and forget everything. */ +export async function disconnectAccount(userId: string, accountId: string): Promise<{ ok: true }> { + const row = await db.linkedAccount.findFirst({ where: { id: accountId, userId } }); + if (!row) return { ok: true }; + if (row.provider === "GOOGLE") await revokeToken(row.refreshToken); + await db.linkedAccount.delete({ where: { id: row.id } }); + return { ok: true }; +} + +export type AccountTest = { + account: LinkedAccountView; + mail: { ok: boolean; detail: string } | null; + calendar: { ok: boolean; detail: string } | null; +}; + +/** Read one thing from each half, and say whether it answered. */ +export async function testAccount(userId: string, accountId: string): Promise { + const row = await db.linkedAccount.findFirst({ where: { id: accountId, userId } }); + if (!row) throw new Error(`No connected account with id ${accountId}`); + + const result: AccountTest = { account: toView(row), mail: null, calendar: null }; + let credentials: ReaderCredentials; + try { + credentials = await credentialsFor(row); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + if (row.features.includes("mail")) result.mail = { ok: false, detail }; + if (row.features.includes("calendar")) result.calendar = { ok: false, detail }; + return result; + } + + const mail = mailReaderFor(credentials); + if (mail) { + try { + const threads = await mail.searchThreads({ newerThanDays: 30, limit: 1 }); + result.mail = { ok: true, detail: threads.length ? "Answered with recent mail." : "Answered, nothing in the last 30 days." }; + } catch (error) { + result.mail = { ok: false, detail: error instanceof Error ? error.message : String(error) }; + } + } + const calendar = calendarReaderFor(credentials); + if (calendar) { + try { + const now = Date.now(); + const events = await calendar.listEvents({ from: new Date(now - 7 * DAY), to: new Date(now + 7 * DAY), limit: 1 }); + result.calendar = { ok: true, detail: events.length ? "Answered with events this fortnight." : "Answered, nothing in the fortnight around today." }; + } catch (error) { + result.calendar = { ok: false, detail: error instanceof Error ? error.message : String(error) }; + } + } + + const failed = [result.mail, result.calendar].find((half) => half && !half.ok); + await db.linkedAccount.update({ + where: { id: row.id }, + data: failed + ? { lastError: failed.detail.slice(0, 500), lastErrorAt: new Date() } + : { lastError: "", lastErrorAt: null, lastUsedAt: new Date() }, + }); + result.account = toView(await db.linkedAccount.findUniqueOrThrow({ where: { id: row.id } })); + return result; +} + +// --------------------------------------------------------------------------- +// Credentials +// --------------------------------------------------------------------------- + +function describe(row: LinkedAccount) { + return `${PROVIDER_LABEL[row.provider]} account ${row.email}`; +} + +/** + * Everything a reader needs, with a live access token for the OAuth + * providers — refreshed when the stored one is within a minute of expiring, + * and the refreshed one written back. Every failure is a sentence the person + * can act on, because "401" on a contact page is not. + */ +async function credentialsFor(row: LinkedAccount): Promise { + const base: ReaderCredentials = { + provider: row.provider, + email: row.email, + features: row.features, + accessToken: row.accessToken, + imapHost: row.imapHost, + imapPort: row.imapPort, + imapUsername: row.imapUsername, + imapPassword: row.imapPassword, + caldavUrl: row.caldavUrl, + caldavUsername: row.caldavUsername, + caldavPassword: row.caldavPassword, + }; + if (row.provider === "IMAP") return base; + + const fresh = + row.accessToken && row.accessTokenExpiresAt && row.accessTokenExpiresAt.getTime() - Date.now() > 60_000; + if (fresh) return base; + + const settings = await getSettings(); + try { + if (row.provider === "GOOGLE") { + const refreshed = await refreshAccessToken(settings, row.refreshToken); + await db.linkedAccount.update({ + where: { id: row.id }, + data: { + accessToken: refreshed.accessToken, + accessTokenExpiresAt: refreshed.expiresAt, + lastError: "", + lastErrorAt: null, + }, + }); + return { ...base, accessToken: refreshed.accessToken }; + } + const refreshed = await refreshMicrosoftToken({ + clientId: settings.microsoftClientId, + clientSecret: settings.microsoftClientSecret, + refreshToken: row.refreshToken, + }); + await db.linkedAccount.update({ + where: { id: row.id }, + data: { + accessToken: refreshed.accessToken, + accessTokenExpiresAt: refreshed.expiresAt, + // Microsoft rotates refresh tokens; keep the newest one. + ...(refreshed.refreshToken ? { refreshToken: refreshed.refreshToken } : {}), + lastError: "", + lastErrorAt: null, + }, + }); + return { ...base, accessToken: refreshed.accessToken }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await db.linkedAccount.update({ + where: { id: row.id }, + data: { lastError: message.slice(0, 500), lastErrorAt: new Date() }, + }); + if (error instanceof ProviderError && error.revoked) { + throw new AccountNotConnectedError( + `${PROVIDER_LABEL[row.provider]} has revoked access to ${row.email} — usually because access was removed from the account, or the instance's app registration changed. Reconnect it under Settings → Connections.`, + ); + } + throw error; + } +} + +async function touch(accountIds: string[]) { + if (accountIds.length === 0) return; + await db.linkedAccount + .updateMany({ where: { id: { in: accountIds } }, data: { lastUsedAt: new Date() } }) + .catch(() => undefined); +} + +/** A row's credentials plus its public view, for one kind of reading. */ +type Prepared = { row: LinkedAccount; view: LinkedAccountView; credentials: ReaderCredentials }; + +/** Every account that provides a feature, credentials resolved, failures collected rather than thrown. */ +async function prepare( + userId: string, + feature: AccountFeature, + accountId?: string, +): Promise<{ ready: Prepared[]; warnings: string[] }> { + const rows = await db.linkedAccount.findMany({ + where: { userId, ...(accountId ? { id: accountId } : {}), features: { has: feature } }, + orderBy: { createdAt: "asc" }, + }); + const ready: Prepared[] = []; + const warnings: string[] = []; + for (const row of rows) { + try { + ready.push({ row, view: toView(row), credentials: await credentialsFor(row) }); + } catch (error) { + warnings.push(`${describe(row)}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { ready, warnings }; +} + +/** Which account a thread or event came from, on every merged result. */ +export type AccountRef = { id: string; provider: AccountProvider; email: string }; + +function ref(view: LinkedAccountView): AccountRef { + return { id: view.id, provider: view.provider, email: view.email }; +} + +/** Thread ids travel as `.` so a read routes back to the right inbox. */ +function scopedId(accountId: string, threadId: string) { + return `${accountId}.${threadId}`; +} + +function unscope(id: string): { accountId: string; threadId: string } { + const dot = id.indexOf("."); + if (dot <= 0) throw new Error("That thread id did not come from list_correspondence or search_email."); + return { accountId: id.slice(0, dot), threadId: id.slice(dot + 1) }; +} + +export type AccountThread = MailThreadSummary & { account: AccountRef }; +export type AccountEvent = CalendarEvent & { account: AccountRef }; + +// --------------------------------------------------------------------------- +// Reading +// --------------------------------------------------------------------------- + +export type MailResult = { threads: AccountThread[]; warnings: string[] }; +export type CalendarResult = { events: AccountEvent[]; warnings: string[] }; + +/** Free text across every mailbox, or one. Gmail's own operators pass through on a Gmail account. */ +export async function searchEmail( + userId: string, + options: { query: string; limit?: number; accountId?: string }, +): Promise { + const query = options.query.trim(); + if (!query) throw new Error("Say what to search for."); + const { ready, warnings } = await prepare(userId, "mail", options.accountId); + if (ready.length === 0 && warnings.length === 0) { + throw new AccountNotConnectedError(`No mailbox is connected. ${CONNECT_HINT}`); + } + const limit = options.limit ?? 20; + const results = await Promise.all( + ready.map(async (account) => { + try { + const reader = mailReaderFor(account.credentials)!; + const threads = await reader.searchThreads({ text: query, limit }); + return threads.map((thread) => ({ ...thread, id: scopedId(account.view.id, thread.id), account: ref(account.view) })); + } catch (error) { + warnings.push(`${describe(account.row)}: ${error instanceof Error ? error.message : String(error)}`); + return []; + } + }), + ); + await touch(ready.map((account) => account.row.id)); + return { + threads: results.flat().sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime()).slice(0, limit), + warnings, + }; +} + +export async function getEmailThread(userId: string, id: string): Promise { + const { accountId, threadId } = unscope(id); + const row = await db.linkedAccount.findFirst({ where: { id: accountId, userId } }); + if (!row) throw new AccountNotConnectedError("The account that thread came from is no longer connected."); + const reader = mailReaderFor(await credentialsFor(row)); + if (!reader) throw new AccountNotConnectedError(`${describe(row)} no longer provides mail.`); + const thread = await reader.getThread(threadId); + await touch([row.id]); + return { ...thread, id, account: ref(toView(row)) }; +} + +export async function searchCalendar( + userId: string, + options: { query?: string; from?: Date; to?: Date; limit?: number; accountId?: string }, +): Promise { + const { ready, warnings } = await prepare(userId, "calendar", options.accountId); + if (ready.length === 0 && warnings.length === 0) { + throw new AccountNotConnectedError(`No calendar is connected. ${CONNECT_HINT}`); + } + const now = Date.now(); + const window = { + from: options.from ?? new Date(now - 30 * DAY), + to: options.to ?? new Date(now + 60 * DAY), + query: options.query?.trim() || undefined, + limit: options.limit ?? 100, + }; + const results = await Promise.all( + ready.map(async (account) => { + try { + const reader = calendarReaderFor(account.credentials)!; + const events = await reader.listEvents(window); + return events.map((event) => ({ ...event, account: ref(account.view) })); + } catch (error) { + warnings.push(`${describe(account.row)}: ${error instanceof Error ? error.message : String(error)}`); + return []; + } + }), + ); + await touch(ready.map((account) => account.row.id)); + return { + events: results.flat().sort((a, b) => a.start.getTime() - b.start.getTime()).slice(0, window.limit), + warnings, + }; +} + +// --------------------------------------------------------------------------- +// Matching the pipeline +// --------------------------------------------------------------------------- + +/** What a thread or an event is matched on: exact addresses, and company domains. */ +export type MatchTerms = { addresses: string[]; domains: string[] }; + +/** "https://www.acme.com/careers" → "acme.com". Empty for nothing usable. */ +export function domainOfWebsite(website: string): string { + const raw = website.trim().toLowerCase(); + if (!raw) return ""; + try { + const host = new URL(raw.includes("://") ? raw : `https://${raw}`).hostname.replace(/^www\./, ""); + if (!host.includes(".") || FREEMAIL.has(host)) return ""; + return host; + } catch { + return ""; + } +} + +function cleanEmail(value: string): string { + const email = value.trim().toLowerCase(); + return email.includes("@") ? email : ""; +} + +function unique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +/** Whether an event has anyone matching on it, the account holder aside. */ +export function eventMatches(event: CalendarEvent, terms: MatchTerms): boolean { + const addresses = new Set(terms.addresses); + const domains = new Set(terms.domains); + const people = [...event.attendees, ...(event.organizer ? [event.organizer] : [])]; + return people.some((person) => addresses.has(person.email) || domains.has(domainOf(person.email))); +} + +export type CorrespondenceSubject = + | { kind: "contact"; id: string } + | { kind: "company"; id: string } + | { kind: "application"; id: string } + | { kind: "resume"; id: string }; + +export type Correspondence = { + subject: CorrespondenceSubject & { name: string }; + terms: MatchTerms; + /** Why the lists may be short, in sentences: no email on the contact, no website on the company. */ + notes: string[]; + mail: AccountThread[] | null; + calendar: AccountEvent[] | null; + /** Why a half is null or an account is missing: not granted, or a provider refused. */ + warnings: string[]; +}; + +/** + * What the pipeline knows to look for, for one record. + * + * A contact is their address. A company is its domain and everyone on file + * there. An application is its company's domain and the people attached to + * it — not everyone at the company, because a second application at the same + * employer has its own recruiter. A resume is every application it was sent + * with. + */ +async function termsFor( + userId: string, + subject: CorrespondenceSubject, +): Promise<{ name: string; terms: MatchTerms; notes: string[] }> { + const notes: string[] = []; + + if (subject.kind === "contact") { + const contact = await db.contact.findFirst({ + where: { id: subject.id, userId, archivedAt: null }, + select: { name: true, email: true }, + }); + if (!contact) throw new Error(`No contact with id ${subject.id}`); + const email = cleanEmail(contact.email); + if (!email) notes.push(`${contact.name} has no email address on file, so there is nothing to match their mail or meetings on.`); + return { name: contact.name, terms: { addresses: unique([email]), domains: [] }, notes }; + } + + if (subject.kind === "company") { + const company = await db.company.findFirst({ + where: { id: subject.id, userId, archivedAt: null }, + select: { + name: true, + website: true, + // Through the join to the person: an archived contact's address must + // not widen what this company matches on. + contacts: { + where: { contact: { archivedAt: null } }, + select: { contact: { select: { email: true } } }, + }, + }, + }); + if (!company) throw new Error(`No company with id ${subject.id}`); + const domain = domainOfWebsite(company.website); + const addresses = unique(company.contacts.map((link) => cleanEmail(link.contact.email))); + if (!domain) { + notes.push( + `${company.name} has no website on file. Set it and everything from that domain will match, not only the people you have added.`, + ); + } + return { name: company.name, terms: { addresses, domains: unique([domain]) }, notes }; + } + + if (subject.kind === "application") { + const application = await db.application.findFirst({ + where: { id: subject.id, userId, archivedAt: null }, + select: { + roleTitle: true, + company: { select: { name: true, website: true } }, + contacts: { where: { archivedAt: null }, select: { email: true } }, + }, + }); + if (!application) throw new Error(`No application with id ${subject.id}`); + const domain = domainOfWebsite(application.company.website); + const addresses = unique(application.contacts.map((contact) => cleanEmail(contact.email))); + if (!domain) { + notes.push( + `${application.company.name} has no website on file, so only the people attached to this application can be matched.`, + ); + } + if (addresses.length === 0 && !domain) { + notes.push("Add the recruiter or hiring manager as a contact with their email, and their threads will appear here."); + } + return { + name: `${application.company.name} — ${application.roleTitle}`, + terms: { addresses, domains: unique([domain]) }, + notes, + }; + } + + const resume = await db.resume.findFirst({ + where: { id: subject.id, userId }, + select: { + name: true, + applications: { + where: { archivedAt: null }, + select: { + company: { select: { website: true } }, + contacts: { where: { archivedAt: null }, select: { email: true } }, + }, + }, + }, + }); + if (!resume) throw new Error(`No resume with id ${subject.id}`); + if (resume.applications.length === 0) { + notes.push("This resume is not attached to any application yet, so there is nothing to match on."); + } + return { + name: resume.name, + terms: { + addresses: unique(resume.applications.flatMap((a) => a.contacts.map((c) => cleanEmail(c.email)))), + domains: unique(resume.applications.map((a) => domainOfWebsite(a.company.website))), + }, + notes, + }; +} + +/** + * Every thread and meeting across the person's accounts that touches one + * record on the pipeline. Each half, and each account within it, fails + * independently — a calendar that was never granted must not hide the mail — + * and says why in `warnings`. + */ +export async function listCorrespondence( + userId: string, + subject: CorrespondenceSubject, + options: { limit?: number; days?: number } = {}, +): Promise { + const { name, terms, notes } = await termsFor(userId, subject); + const empty = terms.addresses.length === 0 && terms.domains.length === 0; + const days = options.days ?? 365; + const limit = options.limit ?? 20; + + const [mailSide, calendarSide] = await Promise.all([ + (async (): Promise<{ threads: AccountThread[] | null; warnings: string[] }> => { + if (empty) return { threads: [], warnings: [] }; + const { ready, warnings } = await prepare(userId, "mail"); + if (ready.length === 0) return { threads: warnings.length ? null : [], warnings }; + const results = await Promise.all( + ready.map(async (account) => { + try { + const reader = mailReaderFor(account.credentials)!; + const threads = await reader.searchThreads({ ...terms, newerThanDays: days, limit }); + return threads.map((thread) => ({ ...thread, id: scopedId(account.view.id, thread.id), account: ref(account.view) })); + } catch (error) { + warnings.push(`${describe(account.row)}: ${error instanceof Error ? error.message : String(error)}`); + return []; + } + }), + ); + await touch(ready.map((account) => account.row.id)); + return { + threads: results.flat().sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime()).slice(0, limit), + warnings, + }; + })(), + (async (): Promise<{ events: AccountEvent[] | null; warnings: string[] }> => { + if (empty) return { events: [], warnings: [] }; + const { ready, warnings } = await prepare(userId, "calendar"); + if (ready.length === 0) return { events: warnings.length ? null : [], warnings }; + const now = Date.now(); + const window = { from: new Date(now - days * DAY), to: new Date(now + 120 * DAY), limit: 500 }; + const results = await Promise.all( + ready.map(async (account) => { + try { + const reader = calendarReaderFor(account.credentials)!; + const events = await reader.listEvents(window); + return events + .filter((event) => eventMatches(event, terms)) + .map((event) => ({ ...event, account: ref(account.view) })); + } catch (error) { + warnings.push(`${describe(account.row)}: ${error instanceof Error ? error.message : String(error)}`); + return []; + } + }), + ); + await touch(ready.map((account) => account.row.id)); + return { + events: results.flat().sort((a, b) => b.start.getTime() - a.start.getTime()), + warnings, + }; + })(), + ]); + + return { + subject: { ...subject, name }, + terms, + notes, + mail: mailSide.threads, + calendar: calendarSide.events, + warnings: [ + ...mailSide.warnings.map((w) => `Mail: ${w}`), + ...calendarSide.warnings.map((w) => `Calendar: ${w}`), + ], + }; +} + +// --------------------------------------------------------------------------- +// The calendar, matched against everything +// --------------------------------------------------------------------------- + +export type MatchedEvent = AccountEvent & { + /** The first record the event was matched to, for a link. */ + applicationId: string | null; + companyId: string | null; + companyName: string | null; + contactId: string | null; + contactName: string | null; +}; + +/** + * Calendar events in a window, across every connected calendar, that + * involve anyone on the pipeline: an attendee at a tracked company's domain, + * or a contact's own address. One request per account, matched here. + * Returns nothing rather than throwing when nothing is connected — this + * feeds the calendar view and list_schedule, where an interview a person has + * not connected yet is not an error. + */ +export async function listMatchedEvents( + userId: string, + from: Date, + to: Date, +): Promise<{ events: MatchedEvent[]; warning: string | null }> { + const { ready, warnings } = await prepare(userId, "calendar"); + if (ready.length === 0) return { events: [], warning: warnings[0] ?? null }; + + const [companies, contacts] = await Promise.all([ + db.company.findMany({ + where: { userId, archivedAt: null }, + select: { + id: true, + name: true, + website: true, + applications: { + where: { closedAt: null, archivedAt: null }, + orderBy: { updatedAt: "desc" }, + take: 1, + select: { id: true }, + }, + }, + }), + db.contact.findMany({ + where: { userId, email: { not: "" }, archivedAt: null }, + select: { + id: true, + name: true, + email: true, + applicationId: true, + companies: { + where: { company: { archivedAt: null } }, + take: 1, + select: { company: { select: { id: true, name: true } } }, + }, + }, + }), + ]); + + const byDomain = new Map(); + for (const company of companies) { + const domain = domainOfWebsite(company.website); + if (domain && !byDomain.has(domain)) byDomain.set(domain, company); + } + const byAddress = new Map(); + for (const contact of contacts) { + const email = cleanEmail(contact.email); + if (email && !byAddress.has(email)) byAddress.set(email, contact); + } + if (byDomain.size === 0 && byAddress.size === 0) return { events: [], warning: null }; + + const results = await Promise.all( + ready.map(async (account) => { + try { + const reader = calendarReaderFor(account.credentials)!; + const events = await reader.listEvents({ from, to, limit: 500 }); + return events.map((event) => ({ ...event, account: ref(account.view) })); + } catch (error) { + warnings.push(`${describe(account.row)}: ${error instanceof Error ? error.message : String(error)}`); + return []; + } + }), + ); + + const matched: MatchedEvent[] = []; + const seen = new Set(); + for (const event of results.flat()) { + const people = [...event.attendees, ...(event.organizer ? [event.organizer] : [])]; + const contact = people.map((person) => byAddress.get(person.email)).find(Boolean); + const company = people.map((person) => byDomain.get(domainOf(person.email))).find(Boolean); + if (!contact && !company) continue; + // The same interview on a work and a personal calendar is one interview. + const key = `${event.title}@${event.start.toISOString()}`; + if (seen.has(key)) continue; + seen.add(key); + const linkedCompany = company ?? contact?.companies[0]?.company ?? null; + matched.push({ + ...event, + applicationId: contact?.applicationId ?? company?.applications[0]?.id ?? null, + companyId: linkedCompany?.id ?? null, + companyName: linkedCompany?.name ?? null, + contactId: contact?.id ?? null, + contactName: contact?.name ?? null, + }); + } + await touch(ready.map((account) => account.row.id)); + return { events: matched.sort((a, b) => a.start.getTime() - b.start.getTime()), warning: warnings[0] ?? null }; +} diff --git a/src/lib/data/google.ts b/src/lib/data/google.ts deleted file mode 100644 index 487ccee..0000000 --- a/src/lib/data/google.ts +++ /dev/null @@ -1,575 +0,0 @@ -import { db } from "@/lib/db"; -import { getSettings } from "@/lib/settings"; -import { - GOOGLE_DATA_SCOPES, - GoogleApiError, - calendarListEvents, - domainOf, - gmailGetThread, - gmailSearchThreads, - refreshAccessToken, - revokeToken, - type CalendarEvent, - type GoogleFeature, - type MailThread, - type MailThreadSummary, -} from "@/lib/google-api"; - -/** - * A person's own Gmail and Google Calendar, read live on their behalf. - * - * Like every file here: userId is the first argument of every function and - * every query filters on it. The token that lets this instance read an inbox - * is the most sensitive thing it holds about anyone, and it never leaves this - * file — callers get what Google said, never the credential that asked. - * - * Nothing Google returns is written to the database. A thread list or a - * meeting is fetched when a screen or a tool asks and shown as it came back, - * so there is no copy of anyone's mail to leak, to go stale, or to be - * subpoenaed from a server that only ever needed to *look*. The cost is a - * round trip to Google on every open, which is why the screens load these - * panels after the page rather than blocking on them. - */ - -const DAY = 24 * 60 * 60 * 1000; - -/** Domains that say nothing about who somebody works for. Never matched as a company. */ -const FREEMAIL = new Set([ - "gmail.com", - "googlemail.com", - "outlook.com", - "hotmail.com", - "live.com", - "yahoo.com", - "icloud.com", - "me.com", - "proton.me", - "protonmail.com", - "aol.com", - "hey.com", - "fastmail.com", -]); - -export class GoogleNotConnectedError extends Error { - constructor(message: string) { - super(message); - this.name = "GoogleNotConnectedError"; - } -} - -// --------------------------------------------------------------------------- -// The connection -// --------------------------------------------------------------------------- - -export type GoogleConnection = { - /** The Google address that was connected. */ - email: string; - /** Whether each half was actually granted on the consent screen. */ - mail: boolean; - calendar: boolean; - connectedAt: Date; - lastUsedAt: Date | null; - /** Non-empty when the last refresh failed and a reconnect is needed. */ - lastError: string; - lastErrorAt: Date | null; -}; - -function toConnection(row: { - email: string; - scopes: string[]; - createdAt: Date; - lastUsedAt: Date | null; - lastError: string; - lastErrorAt: Date | null; -}): GoogleConnection { - return { - email: row.email, - mail: row.scopes.includes(GOOGLE_DATA_SCOPES.mail), - calendar: row.scopes.includes(GOOGLE_DATA_SCOPES.calendar), - connectedAt: row.createdAt, - lastUsedAt: row.lastUsedAt, - lastError: row.lastError, - lastErrorAt: row.lastErrorAt, - }; -} - -/** Status without secrets — safe for a tool result and for the browser. */ -export async function getGoogleConnection(userId: string): Promise { - const row = await db.googleAccount.findUnique({ where: { userId } }); - return row ? toConnection(row) : null; -} - -export type GoogleConnectInput = { - email: string; - googleId: string; - scopes: string[]; - refreshToken: string; - accessToken: string; - expiresAt: Date; -}; - -/** - * Store what the consent screen handed back. Replaces any earlier connection - * outright — there is one inbox per workspace, and reconnecting is how a - * person switches which one, or recovers from a revoked token. - */ -export async function connectGoogleAccount(userId: string, input: GoogleConnectInput) { - const scopes = input.scopes.filter((scope) => - (Object.values(GOOGLE_DATA_SCOPES) as string[]).includes(scope), - ); - if (scopes.length === 0) throw new Error("Neither Gmail nor Calendar was granted."); - if (!input.refreshToken) throw new Error("Google did not return a refresh token."); - - const previous = await db.googleAccount.findUnique({ where: { userId } }); - if (previous && previous.refreshToken !== input.refreshToken) { - await revokeToken(previous.refreshToken); - } - - const data = { - email: input.email, - googleId: input.googleId, - scopes, - refreshToken: input.refreshToken, - accessToken: input.accessToken, - accessTokenExpiresAt: input.expiresAt, - lastError: "", - lastErrorAt: null, - }; - const row = await db.googleAccount.upsert({ - where: { userId }, - create: { userId, ...data }, - update: data, - }); - return toConnection(row); -} - -/** Revoke at Google and forget everything. Safe to call when nothing is connected. */ -export async function disconnectGoogleAccount(userId: string): Promise<{ ok: true }> { - const row = await db.googleAccount.findUnique({ where: { userId } }); - if (!row) return { ok: true }; - await revokeToken(row.refreshToken); - await db.googleAccount.delete({ where: { userId } }); - return { ok: true }; -} - -/** - * A live access token for one feature, refreshing when the stored one is - * within a minute of expiring. Every failure to get one is reported as a - * sentence the person can act on, because "401" on a contact page is not. - */ -async function accessTokenFor(userId: string, feature: GoogleFeature) { - const row = await db.googleAccount.findUnique({ where: { userId } }); - if (!row) { - throw new GoogleNotConnectedError( - "Google is not connected. Connect Gmail and Calendar under Settings → Connections in the app, then try again.", - ); - } - if (!row.scopes.includes(GOOGLE_DATA_SCOPES[feature])) { - throw new GoogleNotConnectedError( - `${feature === "mail" ? "Gmail" : "Google Calendar"} was not allowed when Google was connected. Reconnect under Settings → Connections and tick it on the consent screen.`, - ); - } - - const fresh = - row.accessToken && row.accessTokenExpiresAt && row.accessTokenExpiresAt.getTime() - Date.now() > 60_000; - if (fresh) { - return { token: row.accessToken, email: row.email }; - } - - const settings = await getSettings(); - try { - const refreshed = await refreshAccessToken(settings, row.refreshToken); - await db.googleAccount.update({ - where: { userId }, - data: { - accessToken: refreshed.accessToken, - accessTokenExpiresAt: refreshed.expiresAt, - lastError: "", - lastErrorAt: null, - }, - }); - return { token: refreshed.accessToken, email: row.email }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - await db.googleAccount.update({ - where: { userId }, - data: { lastError: message.slice(0, 500), lastErrorAt: new Date() }, - }); - if (error instanceof GoogleApiError && error.revoked) { - throw new GoogleNotConnectedError( - "Google has revoked this connection — usually because access was removed from the Google account, or the instance's OAuth client changed. Reconnect under Settings → Connections.", - ); - } - throw error; - } -} - -async function touch(userId: string) { - await db.googleAccount - .update({ where: { userId }, data: { lastUsedAt: new Date() } }) - .catch(() => undefined); -} - -// --------------------------------------------------------------------------- -// Reading -// --------------------------------------------------------------------------- - -/** Gmail's own search syntax, or plain words. */ -export async function searchEmail( - userId: string, - options: { query: string; limit?: number }, -): Promise { - const query = options.query.trim(); - if (!query) throw new Error("Say what to search for."); - const { token, email } = await accessTokenFor(userId, "mail"); - const threads = await gmailSearchThreads(token, email, query, options.limit ?? 20); - await touch(userId); - return threads; -} - -export async function getEmailThread(userId: string, threadId: string): Promise { - const { token, email } = await accessTokenFor(userId, "mail"); - const thread = await gmailGetThread(token, email, threadId); - await touch(userId); - return thread; -} - -export async function searchCalendar( - userId: string, - options: { query?: string; from?: Date; to?: Date; limit?: number }, -): Promise { - const { token } = await accessTokenFor(userId, "calendar"); - const now = Date.now(); - const events = await calendarListEvents(token, { - from: options.from ?? new Date(now - 30 * DAY), - to: options.to ?? new Date(now + 60 * DAY), - query: options.query?.trim() || undefined, - limit: options.limit ?? 100, - }); - await touch(userId); - return events; -} - -// --------------------------------------------------------------------------- -// Matching the pipeline -// --------------------------------------------------------------------------- - -/** What a thread or an event is matched on: exact addresses, and company domains. */ -export type MatchTerms = { addresses: string[]; domains: string[] }; - -/** "https://www.acme.com/careers" → "acme.com". Empty for nothing usable. */ -export function domainOfWebsite(website: string): string { - const raw = website.trim().toLowerCase(); - if (!raw) return ""; - try { - const host = new URL(raw.includes("://") ? raw : `https://${raw}`).hostname.replace(/^www\./, ""); - if (!host.includes(".") || FREEMAIL.has(host)) return ""; - return host; - } catch { - return ""; - } -} - -function cleanEmail(value: string): string { - const email = value.trim().toLowerCase(); - return email.includes("@") ? email : ""; -} - -function unique(values: string[]): string[] { - return [...new Set(values.filter(Boolean))]; -} - -/** - * The Gmail query for a set of terms. Braces are Gmail's OR group; - * `from:acme.com` matches every address at that domain. Nothing is excluded - * on purpose — a rejection that Gmail filed under Promotions is still the - * rejection. - */ -export function gmailQueryFor(terms: MatchTerms): string { - const clauses = [ - ...terms.addresses.flatMap((address) => [`from:${address}`, `to:${address}`, `cc:${address}`]), - ...terms.domains.flatMap((domain) => [`from:${domain}`, `to:${domain}`, `cc:${domain}`]), - ]; - return clauses.length ? `{${clauses.join(" ")}}` : ""; -} - -/** Whether an event has anyone matching on it, the account holder aside. */ -export function eventMatches(event: CalendarEvent, terms: MatchTerms): boolean { - const addresses = new Set(terms.addresses); - const domains = new Set(terms.domains); - const people = [...event.attendees, ...(event.organizer ? [event.organizer] : [])]; - return people.some( - (person) => addresses.has(person.email) || domains.has(domainOf(person.email)), - ); -} - -export type CorrespondenceSubject = - | { kind: "contact"; id: string } - | { kind: "company"; id: string } - | { kind: "application"; id: string } - | { kind: "resume"; id: string }; - -export type Correspondence = { - subject: CorrespondenceSubject & { name: string }; - terms: MatchTerms; - /** Why the lists may be short, in sentences: no email on the contact, no website on the company. */ - notes: string[]; - mail: MailThreadSummary[] | null; - calendar: CalendarEvent[] | null; - /** Why a half is null: not granted, or Google refused. Empty when both answered. */ - warnings: string[]; -}; - -/** - * What the pipeline knows to look for, for one record. - * - * A contact is their address. A company is its domain and everyone on file - * there. An application is its company's domain and the people attached to - * it — not everyone at the company, because a second application at the same - * employer has its own recruiter. A resume is every application it was sent - * with. - */ -async function termsFor( - userId: string, - subject: CorrespondenceSubject, -): Promise<{ name: string; terms: MatchTerms; notes: string[] }> { - const notes: string[] = []; - - if (subject.kind === "contact") { - const contact = await db.contact.findFirst({ - where: { id: subject.id, userId, archivedAt: null }, - select: { name: true, email: true }, - }); - if (!contact) throw new Error(`No contact with id ${subject.id}`); - const email = cleanEmail(contact.email); - if (!email) notes.push(`${contact.name} has no email address on file, so there is nothing to match their mail or meetings on.`); - return { name: contact.name, terms: { addresses: unique([email]), domains: [] }, notes }; - } - - if (subject.kind === "company") { - const company = await db.company.findFirst({ - where: { id: subject.id, userId, archivedAt: null }, - select: { - name: true, - website: true, - // Through the join to the person: an archived contact's address must - // not widen what this company matches on. - contacts: { - where: { contact: { archivedAt: null } }, - select: { contact: { select: { email: true } } }, - }, - }, - }); - if (!company) throw new Error(`No company with id ${subject.id}`); - const domain = domainOfWebsite(company.website); - const addresses = unique(company.contacts.map((link) => cleanEmail(link.contact.email))); - if (!domain) { - notes.push( - `${company.name} has no website on file. Set it and everything from that domain will match, not only the people you have added.`, - ); - } - return { name: company.name, terms: { addresses, domains: unique([domain]) }, notes }; - } - - if (subject.kind === "application") { - const application = await db.application.findFirst({ - where: { id: subject.id, userId, archivedAt: null }, - select: { - roleTitle: true, - company: { select: { name: true, website: true } }, - contacts: { where: { archivedAt: null }, select: { email: true } }, - }, - }); - if (!application) throw new Error(`No application with id ${subject.id}`); - const domain = domainOfWebsite(application.company.website); - const addresses = unique(application.contacts.map((contact) => cleanEmail(contact.email))); - if (!domain) { - notes.push( - `${application.company.name} has no website on file, so only the people attached to this application can be matched.`, - ); - } - if (addresses.length === 0 && !domain) { - notes.push("Add the recruiter or hiring manager as a contact with their email, and their threads will appear here."); - } - return { - name: `${application.company.name} — ${application.roleTitle}`, - terms: { addresses, domains: unique([domain]) }, - notes, - }; - } - - const resume = await db.resume.findFirst({ - where: { id: subject.id, userId }, - select: { - name: true, - applications: { - where: { archivedAt: null }, - select: { - company: { select: { website: true } }, - contacts: { where: { archivedAt: null }, select: { email: true } }, - }, - }, - }, - }); - if (!resume) throw new Error(`No resume with id ${subject.id}`); - if (resume.applications.length === 0) { - notes.push("This resume is not attached to any application yet, so there is nothing to match on."); - } - return { - name: resume.name, - terms: { - addresses: unique(resume.applications.flatMap((a) => a.contacts.map((c) => cleanEmail(c.email)))), - domains: unique(resume.applications.map((a) => domainOfWebsite(a.company.website))), - }, - notes, - }; -} - -/** - * Every thread and meeting Google has that touches one record on the - * pipeline. Each half fails independently — a Calendar that was never - * granted must not hide the mail — and says why in `warnings`. - */ -export async function listCorrespondence( - userId: string, - subject: CorrespondenceSubject, - options: { limit?: number; days?: number } = {}, -): Promise { - const { name, terms, notes } = await termsFor(userId, subject); - const warnings: string[] = []; - const empty = terms.addresses.length === 0 && terms.domains.length === 0; - const days = options.days ?? 365; - - const [mail, calendar] = await Promise.all([ - (async () => { - if (empty) return []; - try { - const { token, email } = await accessTokenFor(userId, "mail"); - const query = `${gmailQueryFor(terms)} newer_than:${days}d`; - return await gmailSearchThreads(token, email, query, options.limit ?? 20); - } catch (error) { - warnings.push(`Mail: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })(), - (async () => { - if (empty) return []; - try { - const { token } = await accessTokenFor(userId, "calendar"); - const now = Date.now(); - const events = await calendarListEvents(token, { - from: new Date(now - days * DAY), - to: new Date(now + 120 * DAY), - limit: 500, - }); - return events.filter((event) => eventMatches(event, terms)).reverse(); - } catch (error) { - warnings.push(`Calendar: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })(), - ]); - - if (!empty) await touch(userId); - return { subject: { ...subject, name }, terms, notes, mail, calendar, warnings }; -} - -// --------------------------------------------------------------------------- -// The calendar, matched against everything -// --------------------------------------------------------------------------- - -export type MatchedEvent = CalendarEvent & { - /** The first record the event was matched to, for a link. */ - applicationId: string | null; - companyId: string | null; - companyName: string | null; - contactId: string | null; - contactName: string | null; -}; - -/** - * Google Calendar events in a window that involve anyone on the pipeline: - * an attendee at a tracked company's domain, or a contact's own address. - * One request to Google, matched here. Returns nothing rather than throwing - * when Google is not connected or not granted — this feeds the calendar view - * and list_schedule, where an interview a person has not connected yet is - * not an error. - */ -export async function listMatchedEvents( - userId: string, - from: Date, - to: Date, -): Promise<{ events: MatchedEvent[]; warning: string | null }> { - const row = await db.googleAccount.findUnique({ where: { userId }, select: { scopes: true } }); - if (!row || !row.scopes.includes(GOOGLE_DATA_SCOPES.calendar)) return { events: [], warning: null }; - - const [companies, contacts] = await Promise.all([ - db.company.findMany({ - where: { userId, archivedAt: null }, - select: { - id: true, - name: true, - website: true, - applications: { - where: { closedAt: null, archivedAt: null }, - orderBy: { updatedAt: "desc" }, - take: 1, - select: { id: true }, - }, - }, - }), - db.contact.findMany({ - where: { userId, email: { not: "" }, archivedAt: null }, - select: { - id: true, - name: true, - email: true, - applicationId: true, - companies: { - where: { company: { archivedAt: null } }, - take: 1, - select: { company: { select: { id: true, name: true } } }, - }, - }, - }), - ]); - - const byDomain = new Map(); - for (const company of companies) { - const domain = domainOfWebsite(company.website); - if (domain && !byDomain.has(domain)) byDomain.set(domain, company); - } - const byAddress = new Map(); - for (const contact of contacts) { - const email = cleanEmail(contact.email); - if (email && !byAddress.has(email)) byAddress.set(email, contact); - } - if (byDomain.size === 0 && byAddress.size === 0) return { events: [], warning: null }; - - let events: CalendarEvent[]; - try { - const { token } = await accessTokenFor(userId, "calendar"); - events = await calendarListEvents(token, { from, to, limit: 500 }); - } catch (error) { - return { events: [], warning: error instanceof Error ? error.message : String(error) }; - } - - const matched: MatchedEvent[] = []; - for (const event of events) { - const people = [...event.attendees, ...(event.organizer ? [event.organizer] : [])]; - const contact = people.map((person) => byAddress.get(person.email)).find(Boolean); - const company = people.map((person) => byDomain.get(domainOf(person.email))).find(Boolean); - if (!contact && !company) continue; - const linkedCompany = company ?? contact?.companies[0]?.company ?? null; - matched.push({ - ...event, - applicationId: contact?.applicationId ?? company?.applications[0]?.id ?? null, - companyId: linkedCompany?.id ?? null, - companyName: linkedCompany?.name ?? null, - contactId: contact?.id ?? null, - contactName: contact?.name ?? null, - }); - } - await touch(userId); - return { events: matched, warning: null }; -} diff --git a/src/lib/data/pipeline.ts b/src/lib/data/pipeline.ts index 69a2b34..de919b9 100644 --- a/src/lib/data/pipeline.ts +++ b/src/lib/data/pipeline.ts @@ -29,7 +29,6 @@ import { sortContacts, } from "@/lib/crm-filters"; import { loadPosting, type ParsedPosting } from "@/lib/posting"; -import { listMatchedEvents } from "@/lib/data/google"; /** Like me.ts: userId is the required first argument on every query. */ @@ -2037,7 +2036,12 @@ export async function dueNow( * is thinking about all three at once. Merging them here rather than in the * calendar component is what lets the same answer come back over MCP. */ -/** MEETING is a Google Calendar event that involves someone on the pipeline. */ +/** + * MEETING is a calendar event from a connected account that involves someone + * on the pipeline. It is merged in by src/lib/data/schedule.ts rather than + * here, so this file — which client components import for its constants — + * never reaches the provider code and its Node-only libraries. + */ export type ScheduleKind = "FOLLOW_UP" | "TASK" | "ACTIVITY" | "MEETING"; export type ScheduleEntry = { @@ -2053,7 +2057,7 @@ export type ScheduleEntry = { stage: Stage | null; done: boolean | null; activityType: ActivityType | null; - /** Set on a MEETING: the event in Google Calendar. */ + /** Set on a MEETING: the event in the provider's own calendar, when it has a page. */ url?: string; }; @@ -2066,7 +2070,7 @@ export async function listSchedule( const end = toDate(to) ?? new Date(); const range = { gte: start, lte: end }; - const [followUps, contactPings, tasks, activities, meetings] = await Promise.all([ + const [followUps, contactPings, tasks, activities] = await Promise.all([ db.application.findMany({ where: { userId, archivedAt: null, nextFollowUpAt: range, stage: { notIn: TERMINAL_STAGES } }, include: { company: true }, @@ -2091,35 +2095,9 @@ export async function listSchedule( contact: { select: { id: true, name: true } }, }, }), - // Google Calendar, when it is connected and granted: interviews and calls - // the person put on their real calendar, matched to the pipeline by who - // is invited. Empty, never an error, when there is no connection. - listMatchedEvents(userId, start, end).then((result) => result.events), ]); const entries: ScheduleEntry[] = [ - ...meetings.map((event) => ({ - kind: "MEETING" as const, - id: event.id, - date: event.start, - title: event.title, - detail: [ - event.contactName, - event.companyName, - event.allDay - ? "All day" - : `${event.start.toISOString().slice(11, 16)}–${event.end.toISOString().slice(11, 16)} UTC`, - ] - .filter(Boolean) - .join(" · "), - company: event.companyName, - applicationId: event.applicationId, - contactId: event.contactId, - stage: null, - done: null, - activityType: null, - url: event.url, - })), ...followUps.map((application) => ({ kind: "FOLLOW_UP" as const, id: application.id, diff --git a/src/lib/data/schedule.ts b/src/lib/data/schedule.ts new file mode 100644 index 0000000..9ab7281 --- /dev/null +++ b/src/lib/data/schedule.ts @@ -0,0 +1,51 @@ +import * as pipeline from "@/lib/data/pipeline"; +import { listMatchedEvents } from "@/lib/data/accounts"; + +/** + * Everything dated in a window: the pipeline's own follow-ups, tasks and + * logged activity, plus — when a calendar is connected — the meetings on the + * person's real calendar that involve someone on the pipeline. + * + * A file of its own rather than a branch in pipeline.ts because the merge + * pulls in the provider layer, whose IMAP library is Node-only, and + * pipeline.ts is imported by client components for its labels and tones. The + * calendar view and list_schedule call this; nothing else needs to. + */ +export async function listSchedule( + userId: string, + from: Date | string, + to: Date | string, +): Promise { + const start = new Date(from); + const end = new Date(to); + const [own, matched] = await Promise.all([ + pipeline.listSchedule(userId, from, to), + // Empty, never an error, when nothing is connected. + listMatchedEvents(userId, start, end).then((result) => result.events), + ]); + + const meetings: pipeline.ScheduleEntry[] = matched.map((event) => ({ + kind: "MEETING" as const, + id: event.id, + date: event.start, + title: event.title, + detail: [ + event.contactName, + event.companyName, + event.allDay + ? "All day" + : `${event.start.toISOString().slice(11, 16)}–${event.end.toISOString().slice(11, 16)} UTC`, + ] + .filter(Boolean) + .join(" · "), + company: event.companyName, + applicationId: event.applicationId, + contactId: event.contactId, + stage: null, + done: null, + activityType: null, + url: event.url, + })); + + return [...own, ...meetings].sort((a, b) => a.date.getTime() - b.date.getTime()); +} diff --git a/src/lib/data/system.ts b/src/lib/data/system.ts index 05b3ea1..58a95a6 100644 --- a/src/lib/data/system.ts +++ b/src/lib/data/system.ts @@ -37,6 +37,7 @@ export type SystemEventSource = | "email.send" | "google.signin" | "google.data" + | "microsoft.data" | "mcp.tool" | "app"; diff --git a/src/lib/google.ts b/src/lib/google.ts index 86b6c0a..76c1bc9 100644 --- a/src/lib/google.ts +++ b/src/lib/google.ts @@ -4,7 +4,7 @@ import { db } from "@/lib/db"; import { CLAIMED, ensureDefaultConnection, isClaimed } from "@/lib/auth"; import { getSettings, googleIsConfigured, type InstanceSettings } from "@/lib/settings"; import { recordSystemEvent } from "@/lib/data/system"; -import { GOOGLE_DATA_SCOPES, type GoogleGrant } from "@/lib/google-api"; +import { GOOGLE_DATA_SCOPES, type GoogleGrant } from "@/lib/accounts/google"; /** * Sign in with Google. @@ -391,9 +391,9 @@ export function refusalMessage(reason: GoogleRefusal, detail = "") { case "cancelled": return "Sign-in was cancelled."; case "no_refresh_token": - return "Google did not hand over a lasting token. Remove Hired under your Google account's third-party access, then connect again."; + return "The provider did not hand over a lasting token. Remove Hired under your account's third-party app access, then connect again."; case "no_scopes": - return "Neither Gmail nor Calendar was allowed on Google's consent screen, so there is nothing to connect. Try again and tick at least one."; + return "Neither mail nor calendar was allowed on the consent screen, so there is nothing to connect. Try again and tick at least one."; default: return "Google sign-in didn't work. Check Admin → Health for the reason."; } diff --git a/src/lib/mcp/handler.ts b/src/lib/mcp/handler.ts index d50cf91..395a315 100644 --- a/src/lib/mcp/handler.ts +++ b/src/lib/mcp/handler.ts @@ -82,13 +82,14 @@ async function instructionsFor(user: User) { mention talking to someone — a call, a coffee, a reply — log_activity with contactId is how it gets remembered, and update_contact's nextFollowUpAt is how "ping them in two weeks" actually happens. list_follow_ups returns due people alongside due applications. -• GMAIL AND CALENDAR — if they have connected their own Google account (get_google_connection - says), list_correspondence returns the real threads and meetings behind any contact, company, - application or resume, read live and never stored here. Call it before saying where an - application stands: the pipeline's timeline only knows what was logged by hand. search_email - and search_calendar cover questions that are not about one record. Every one of these is - read-only — nothing can send, accept or delete. When they are not connected, say how - (Settings → Connections) rather than guessing at their mail. +• MAIL AND CALENDAR — if they have connected an account (list_linked_accounts says: Google, + Microsoft 365, or any IMAP and CalDAV provider), list_correspondence returns the real threads + and meetings behind any contact, company, application or resume, read live across every + account and never stored here. Call it before saying where an application stands: the + pipeline's timeline only knows what was logged by hand. search_email and search_calendar + cover questions that are not about one record. Every one of these is read-only — nothing can + send, accept or delete. When nothing is connected, say how (Settings → Connections, or + connect_imap_account with an app password) rather than guessing at their mail. • TAGS cut across all of it. Where an application came from, a company's industry, size and location, how you know a person — every one of those is a tag rather than a free-text field, and they are multi-select. Call list_tags before writing any of them: passing a name that diff --git a/src/lib/mcp/marks.ts b/src/lib/mcp/marks.ts index c3290bd..6da4ba2 100644 --- a/src/lib/mcp/marks.ts +++ b/src/lib/mcp/marks.ts @@ -39,6 +39,22 @@ export const CLIENT_MARKS: Record = { dark: "#8AB4F8", path: "M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z", }, + microsoft: { + // Outlook's envelope-and-O, in the product blue. + light: "#0F6CBD", + dark: "#4FA3E8", + path: "M24 7.387v10.478c0 .23-.08.424-.24.582a.8.8 0 0 1-.585.238h-.031l-8.57-4.88v5.83a.81.81 0 0 1-.246.593.81.81 0 0 1-.594.246H.84a.81.81 0 0 1-.594-.246A.81.81 0 0 1 0 19.635V4.365c0-.23.082-.428.246-.594A.81.81 0 0 1 .84 3.525h12.894c.23 0 .428.082.594.246a.81.81 0 0 1 .246.594v3.148l8.601-4.93a.8.8 0 0 1 .585.238c.16.158.24.352.24.582V7.387zm-9.426 4.79 8.46-4.822-8.46-4.83v9.652zM7.36 15.807c.85 0 1.596-.199 2.24-.598.643-.399 1.14-.955 1.492-1.67.352-.714.528-1.53.528-2.449 0-.9-.17-1.702-.51-2.406-.34-.703-.827-1.25-1.46-1.64-.634-.39-1.37-.585-2.21-.585-.87 0-1.63.2-2.28.6-.65.4-1.152.96-1.505 1.68-.353.72-.53 1.54-.53 2.46 0 .88.17 1.67.512 2.37.34.7.83 1.25 1.466 1.65.636.4 1.38.6 2.256.6zm.05-1.78c-.74 0-1.32-.28-1.74-.84-.42-.56-.63-1.31-.63-2.25 0-.96.21-1.72.64-2.28.43-.56 1.02-.84 1.77-.84.72 0 1.29.28 1.7.84.41.56.62 1.32.62 2.28 0 .95-.21 1.7-.62 2.26-.41.56-.99.84-1.74.83z", + }, + slack: { + light: "#4A154B", + dark: "#E8D5EA", + path: "M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z", + }, + discord: { + light: "#5865F2", + dark: "#8891FF", + path: "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z", + }, claude: { light: "#D97757", path: "m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z", diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index fbf0ab1..8f35cff 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -34,13 +34,15 @@ import { import { parsePipelineFilters } from "@/lib/pipeline-filters"; import * as connections from "@/lib/data/connections"; import * as onboarding from "@/lib/data/onboarding"; -import * as google from "@/lib/data/google"; +import * as accountsData from "@/lib/data/accounts"; +import * as schedule from "@/lib/data/schedule"; import { getSettings, updateSettings, emailIsConfigured, billingIsConfigured, googleIsConfigured, + microsoftIsConfigured, maskSecret, listVariables, setVariables, @@ -287,7 +289,7 @@ function withoutPhotoBytes(profile: T) { * list_correspondence takes exactly one of four ids. Two would be a question * with two answers, none is not a question. */ -function correspondenceSubject(args: Json): google.CorrespondenceSubject { +function correspondenceSubject(args: Json): accountsData.CorrespondenceSubject { const given = ( [ ["contact", s(args, "contactId")], @@ -295,7 +297,7 @@ function correspondenceSubject(args: Json): google.CorrespondenceSubject { ["application", s(args, "applicationId")], ["resume", s(args, "resumeId")], ] as const - ).filter((entry): entry is readonly [google.CorrespondenceSubject["kind"], string] => + ).filter((entry): entry is readonly [accountsData.CorrespondenceSubject["kind"], string] => Boolean(entry[1]?.trim()), ); if (given.length !== 1) { @@ -2546,7 +2548,7 @@ export const tools: McpTool[] = [ openWorldHint: false, }, handler: async (args, ctx) => - pipeline.listSchedule(ctx.userId, required(args, "from"), endOfDay(required(args, "to"))), + schedule.listSchedule(ctx.userId, required(args, "from"), endOfDay(required(args, "to"))), }, { name: "list_tasks", @@ -3634,16 +3636,17 @@ export const tools: McpTool[] = [ }, // ------------------------------------------------------------------------- - // GMAIL AND CALENDAR + // MAIL AND CALENDAR // - // Read live from the person's own Google account, never copied here. Every - // tool in this section reaches Google, so openWorldHint is true throughout. + // Read live from the person's own accounts — Google, Microsoft 365, or any + // IMAP and CalDAV provider — never copied here. Every tool in this section + // reaches outside the instance, so openWorldHint is true throughout. // ------------------------------------------------------------------------- { - name: "get_google_connection", - title: "Is Gmail and Calendar connected", + name: "list_linked_accounts", + title: "Which mail and calendar accounts are connected", description: - "Whether this person has connected their Gmail and Google Calendar, which of the two was granted, which Google address it is, and whether the connection has broken and needs reconnecting. Call this first when a mail or calendar tool fails, or before promising to look something up in their inbox. Connecting cannot be done from here — it is a consent screen at Google — so when `connected` is false, tell them to open Settings → Connections in the app, open the Google tile and press Connect, then come back. Nothing in the inbox is stored on this instance: every read is live, and disconnecting deletes the only thing held, the token.", + "Every mailbox and calendar this person has connected for the app to read — Google, Microsoft 365, or an IMAP and CalDAV provider such as Fastmail or iCloud — with which of mail and calendar each provides, its address, when it was last read, and whether it has broken and needs reconnecting (`lastError`). Call this first when a mail or calendar tool fails, or before promising to look something up in their inbox. Google and Microsoft connect through a consent screen in a browser, so they cannot be connected from here: when nothing is listed, tell them to open Settings → Connections in the app and add an account. An IMAP account can be connected with connect_imap_account. Nothing from any inbox is stored on this instance: every read is live, and disconnecting deletes the only thing held, the credential.", inputSchema: object({}), annotations: { readOnlyHint: true, @@ -3652,20 +3655,91 @@ export const tools: McpTool[] = [ openWorldHint: false, }, handler: async (_args, ctx) => { - const connection = await google.getGoogleConnection(ctx.userId); - return connection - ? { connected: true, ...connection, connectUrl: `${ctx.baseUrl}/settings?tab=connections` } - : { - connected: false, - howToConnect: `Open ${ctx.baseUrl}/settings?tab=connections and open the Google tile, then press Connect Google. It asks for read-only access to Gmail and Calendar; either can be left unticked.`, - }; + const accounts = await accountsData.listLinkedAccounts(ctx.userId); + return { + accounts, + connectUrl: `${ctx.baseUrl}/settings?tab=connections`, + ...(accounts.length === 0 + ? { + howToConnect: `Open ${ctx.baseUrl}/settings?tab=connections and add an account. Google and Microsoft 365 ask for read-only access to mail and calendar on a consent screen; any other provider takes an IMAP server and a CalDAV URL with an app password, which connect_imap_account can also do from here.`, + } + : {}), + }; + }, + }, + { + name: "connect_imap_account", + title: "Connect a mailbox by IMAP and a calendar by CalDAV", + description: + "Connect any mail provider that is not Google or Microsoft — Fastmail, iCloud, Yahoo, a university account, a self-hosted server — by its IMAP server and, optionally, its CalDAV URL. Either half may be left out. Both are logged in to before anything is saved, so a wrong password is an error now rather than a broken tile later. The password must be an APP PASSWORD generated in the provider's security settings, never the account password; say so before asking for one, and never repeat it back or write it anywhere. Connecting an address that is already connected replaces its stored details. Presets worth knowing: Fastmail is imap.fastmail.com with CalDAV at https://caldav.fastmail.com/; iCloud is imap.mail.me.com with CalDAV at https://caldav.icloud.com/; Yahoo is imap.mail.yahoo.com. Read-only: the app can never send, move or delete anything with what it stores.", + inputSchema: object( + { + email: str("The address of the mailbox."), + label: str("What to call it on the tile, e.g. 'Work' or 'Old university address'. Optional."), + imapHost: str("IMAP server, e.g. imap.fastmail.com. Leave out for a calendar-only account."), + imapPort: num("IMAP port. Default 993 (TLS)."), + imapUsername: str("IMAP username. Defaults to the address."), + imapPassword: str("An app password for IMAP. Never the account password."), + caldavUrl: str("CalDAV server or calendar-home URL, e.g. https://caldav.fastmail.com/. Leave out for a mail-only account."), + caldavUsername: str("CalDAV username. Defaults to the IMAP username, then the address."), + caldavPassword: str("An app password for CalDAV. Defaults to the IMAP password — many providers use one for both."), + }, + ["email"], + ), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, }, + handler: async (args, ctx) => + accountsData.connectImapAccount(ctx.userId, { + email: required(args, "email"), + ...defined({ + label: s(args, "label"), + imapHost: s(args, "imapHost"), + imapPort: n(args, "imapPort"), + imapUsername: s(args, "imapUsername"), + imapPassword: s(args, "imapPassword"), + caldavUrl: s(args, "caldavUrl"), + caldavUsername: s(args, "caldavUsername"), + caldavPassword: s(args, "caldavPassword"), + }), + }), + }, + { + name: "test_linked_account", + title: "Test a connected account", + description: + "Read one thing from each half of a connected account — the most recent mail, the events around today — and report whether it answered, with the provider's own words when it did not. The way to find out whether 'no threads' means an empty result or a dead connection. Get the id from list_linked_accounts. Records the outcome on the account (`lastError`), which is why it is not marked read-only.", + inputSchema: object({ accountId: str("The account id from list_linked_accounts.") }, ["accountId"]), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (args, ctx) => accountsData.testAccount(ctx.userId, required(args, "accountId")), + }, + { + name: "disconnect_account", + title: "Disconnect a mail or calendar account", + description: + "Forget one connected account: revoke this instance's access where the provider allows it (Google) and delete the credential. Every read from that account stops immediately; nothing else — no contact, application or logged activity — is touched, because nothing from it was ever stored. Other accounts stay connected. Confirm before calling it. Get the id from list_linked_accounts; reconnecting is the same consent screen or form as the first time, under Settings → Connections.", + inputSchema: object({ accountId: str("The account id from list_linked_accounts.") }, ["accountId"]), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (args, ctx) => accountsData.disconnectAccount(ctx.userId, required(args, "accountId")), }, { name: "list_correspondence", title: "Mail and meetings about one record", description: - "Every email thread and calendar event in the person's own Google account that involves one thing on the pipeline: a contact (matched on their email address), a company (its website's domain plus everyone on file there), an application (its company's domain plus the people attached to it) or a resume (every application it was sent with). This is the tool for 'what's the latest with Stripe', 'have I heard back from Jane', 'when is my interview' and 'what did the recruiter actually say' — call it before summarising where an application stands, because the pipeline's timeline only knows what was logged by hand. Pass exactly one id. Returns `mail` (threads, newest first, with subject, snippet, participants and a link) and `calendar` (past and upcoming events, with attendees, a Meet link and a link) — either is null when that half is not granted or Google refused, with the reason in `warnings`. `notes` explains a thin result, usually a contact with no email or a company with no website; fix those with update_contact and update_company and call again. Nothing is saved. To read a thread in full, pass its id to get_email_thread; to remember what you learned, log_activity on the application or contact.", + "Every email thread and calendar event, across all of the person's connected accounts, that involves one thing on the pipeline: a contact (matched on their email address), a company (its website's domain plus everyone on file there), an application (its company's domain plus the people attached to it) or a resume (every application it was sent with). This is the tool for 'what's the latest with Stripe', 'have I heard back from Jane', 'when is my interview' and 'what did the recruiter actually say' — call it before summarising where an application stands, because the pipeline's timeline only knows what was logged by hand. Pass exactly one id. Returns `mail` (threads, newest first, with subject, snippet, participants, which `account` it came from and a link where the provider has one) and `calendar` (past and upcoming events, with attendees, a meeting link and a link) — either is null when no account provides that half or every account refused, with the reason in `warnings`. `notes` explains a thin result, usually a contact with no email or a company with no website; fix those with update_contact and update_company and call again. Nothing is saved. To read a thread in full, pass its id to get_email_thread; to remember what you learned, log_activity on the application or contact.", inputSchema: object({ contactId: str("A contact id. Matches their email address."), companyId: str("A company id. Matches its website's domain and the addresses of its people."), @@ -3682,20 +3756,21 @@ export const tools: McpTool[] = [ }, handler: async (args, ctx) => { const subject = correspondenceSubject(args); - return google.listCorrespondence(ctx.userId, subject, { + return accountsData.listCorrespondence(ctx.userId, subject, { ...defined({ limit: n(args, "limit"), days: n(args, "days") }), }); }, }, { name: "search_email", - title: "Search Gmail", + title: "Search mail", description: - "Search the person's Gmail with Gmail's own query syntax — `from:jane@acme.com`, `subject:offer newer_than:7d`, `\"phone screen\"` — or plain words. Reach for this when the question is about mail that does not map to one record: 'did any rejections come in this week', 'find the email with the take-home', 'who have I emailed about referrals'. For mail about a specific contact, company or application, list_correspondence already builds the right query. Returns threads newest first with subject, Gmail's snippet of the latest message, everyone on the thread, when it last moved and a link that opens it in Gmail. Subjects and snippets only — pass a thread id to get_email_thread for the messages themselves. Read-only; nothing is saved, and this tool cannot send, archive or delete anything.", + "Search every connected mailbox, or one of them, for free text — 'take-home', 'phone screen', a recruiter's name. On a Gmail account, Gmail's own operators work too: `from:jane@acme.com`, `subject:offer newer_than:7d`, `has:attachment`. Reach for this when the question is about mail that does not map to one record: 'did any rejections come in this week', 'find the email with the take-home', 'who have I emailed about referrals'. For mail about a specific contact, company or application, list_correspondence already builds the right query. Returns threads newest first, merged across accounts, each with its subject, a snippet of the latest message (empty on IMAP), everyone on the thread, when it last moved, which `account` it is in, and a link that opens it in the provider's client where there is one. Subjects and snippets only — pass a thread id to get_email_thread for the messages themselves. `warnings` names any account that did not answer. Read-only; nothing is saved, and this tool cannot send, archive or delete anything.", inputSchema: object( { - query: str("A Gmail search. Operators like from:, to:, subject:, newer_than:7d, has:attachment and label: all work, as do plain words."), + query: str("Words to search for. Gmail operators pass through on a Gmail account."), limit: num("How many threads at most. Default 20, maximum 50."), + accountId: str("Search one account only. Omit for all of them."), }, ["query"], ), @@ -3706,16 +3781,16 @@ export const tools: McpTool[] = [ openWorldHint: true, }, handler: async (args, ctx) => - google.searchEmail(ctx.userId, { + accountsData.searchEmail(ctx.userId, { query: required(args, "query"), - ...defined({ limit: n(args, "limit") }), + ...defined({ limit: n(args, "limit"), accountId: s(args, "accountId") }), }), }, { name: "get_email_thread", title: "Read an email thread", description: - "One thread in full, oldest message first: who sent each message, to whom, when, and the body as plain text (HTML mail is stripped to text; attachments are never fetched; very long messages are cut). The id comes from list_correspondence or search_email. This is how you find out what a recruiter actually wrote — the dates they proposed, the salary they named, the next step they described — before logging it with log_activity or moving the application with move_application_stage. Quote the mail when you report it; do not paraphrase a number. Read-only, and nothing about the thread changes: it is not marked read.", + "One thread in full, oldest message first: who sent each message, to whom, when, and the body as plain text (HTML mail is stripped to text; attachments are never fetched; very long messages are cut). The id comes from list_correspondence or search_email and already says which account it lives in. This is how you find out what a recruiter actually wrote — the dates they proposed, the salary they named, the next step they described — before logging it with log_activity or moving the application with move_application_stage. Quote the mail when you report it; do not paraphrase a number. Read-only, and nothing about the thread changes: it is not marked read.", inputSchema: object({ threadId: str("The thread id from list_correspondence or search_email.") }, ["threadId"]), annotations: { readOnlyHint: true, @@ -3723,18 +3798,19 @@ export const tools: McpTool[] = [ idempotentHint: true, openWorldHint: true, }, - handler: async (args, ctx) => google.getEmailThread(ctx.userId, required(args, "threadId")), + handler: async (args, ctx) => accountsData.getEmailThread(ctx.userId, required(args, "threadId")), }, { name: "search_calendar", - title: "Search Google Calendar", + title: "Search calendars", description: - "Events on the person's primary Google Calendar in a window, optionally filtered by a free-text search over title, description, location and attendee addresses. Use it for 'what interviews do I have this week', 'when did I last meet anyone from Acme' or 'am I free Thursday afternoon' — for a whole week of the pipeline's own dates alongside these meetings, list_schedule merges both. Defaults to thirty days back and sixty ahead. Each event has its title, start and end, whether it is all-day, the attendees with their RSVP, the organizer, a Meet link when there is one, and a link to the event. Read-only; nothing here creates, accepts or declines anything.", + "Events across every connected calendar, or one of them, in a window, optionally filtered by words matched against title, description, location and attendee names and addresses. Use it for 'what interviews do I have this week', 'when did I last meet anyone from Acme' or 'am I free Thursday afternoon' — for a whole week of the pipeline's own dates alongside these meetings, list_schedule merges both. Defaults to thirty days back and sixty ahead. Each event has its title, start and end, whether it is all-day, the attendees with their RSVP, the organizer, a meeting link when there is one, which `account` it is on, and a link to the event where the provider has one. `warnings` names any account that did not answer. Read-only; nothing here creates, accepts or declines anything.", inputSchema: object({ - query: str("Words to match against title, description, location and attendee emails. Omit for every event in the window."), + query: str("Words to match against title, description, location and attendees. Omit for every event in the window."), from: str("Start of the window, ISO date (YYYY-MM-DD). Default: 30 days ago."), to: str("End of the window, ISO date (YYYY-MM-DD), inclusive. Default: 60 days ahead."), limit: num("How many events at most. Default 100."), + accountId: str("Search one account only. Omit for all of them."), }), annotations: { readOnlyHint: true, @@ -3743,29 +3819,16 @@ export const tools: McpTool[] = [ openWorldHint: true, }, handler: async (args, ctx) => - google.searchCalendar(ctx.userId, { + accountsData.searchCalendar(ctx.userId, { ...defined({ query: s(args, "query"), from: s(args, "from") ? startOfDay(required(args, "from")) : undefined, to: s(args, "to") ? endOfDay(required(args, "to")) : undefined, limit: n(args, "limit"), + accountId: s(args, "accountId"), }), }), }, - { - name: "disconnect_google", - title: "Disconnect Gmail and Calendar", - description: - "Revoke this instance's access to the person's Gmail and Google Calendar and forget the token. Every mail and calendar tool stops working immediately and the panels in the app go back to offering a Connect button; nothing else — no contact, application or logged activity — is touched, because nothing from Google was ever stored. Confirm before calling it. Reconnecting is the same consent screen as the first time, under Settings → Connections.", - inputSchema: object({}), - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: true, - }, - handler: async (_args, ctx) => google.disconnectGoogleAccount(ctx.userId), - }, // ------------------------------------------------------------------------- // ACCOUNT @@ -4402,6 +4465,58 @@ export const tools: McpTool[] = [ : { ok: false, to, template, error: result.error }; }, }, + { + name: "admin_get_microsoft_config", + title: "Check the Microsoft 365 app registration", + description: + "Whether members can connect their Microsoft 365 or Outlook.com mail and calendar, and the exact redirect URI to register on the app registration in Microsoft Entra — the value behind AADSTS50011 when it does not match. The client secret comes back masked. This is separate from Google sign-in and from the Google client: it is never used to sign in, only for members who choose to connect an Outlook mailbox under Settings → Connections.", + inputSchema: object({}), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + adminOnly: true, + handler: async (_args, ctx) => { + const settings = await getSettings(); + return { + configured: microsoftIsConfigured(settings), + clientId: settings.microsoftClientId, + clientSecret: maskSecret(settings.microsoftClientSecret), + redirectUri: `${ctx.baseUrl}/api/auth/microsoft/callback`, + help: "In Microsoft Entra admin center: App registrations → New registration. Supported account types: 'Accounts in any organizational directory and personal Microsoft accounts', so both work and personal mailboxes can connect. Platform: Web, with redirectUri as the redirect URI. Under API permissions add Microsoft Graph delegated permissions Mail.Read, Calendars.Read, User.Read and offline_access. Under Certificates & secrets create a client secret and paste its VALUE here with admin_set_microsoft_config — the value is shown once.", + }; + }, + }, + { + name: "admin_set_microsoft_config", + title: "Configure the Microsoft 365 app registration", + description: + "Set or clear the Microsoft Entra app registration members use to connect Outlook mail and calendar. Pass the Application (client) ID and a client secret VALUE. Clearing the client id takes the Microsoft option off the account picker; accounts already connected keep working until their token needs refreshing, then show as needing reconnecting. Recorded in the admin audit log; the secret is recorded as having changed, never as its value.", + inputSchema: object({ + clientId: str("The Application (client) ID. Empty string to turn the option off."), + clientSecret: str("A client secret value from Certificates & secrets. Omit to keep the current one."), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + adminOnly: true, + handler: async (args, ctx) => { + await updateSettings( + ctx.user, + defined({ + microsoftClientId: s(args, "clientId")?.trim(), + microsoftClientSecret: s(args, "clientSecret"), + }), + ); + const settings = await getSettings(); + return { configured: microsoftIsConfigured(settings), clientId: settings.microsoftClientId }; + }, + }, { name: "admin_get_google_config", title: "Check Google sign-in", diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 273883c..53e2a5d 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -33,6 +33,8 @@ export const SETTING_KEYS = { googleClientSecret: "google_client_secret", googleAllowSignup: "google_allow_signup", googleAllowedDomains: "google_allowed_domains", + microsoftClientId: "microsoft_client_id", + microsoftClientSecret: "microsoft_client_secret", stripeSecretKey: "stripe_secret_key", stripeWebhookSecret: "stripe_webhook_secret", stripePaymentLink: "stripe_payment_link", @@ -60,6 +62,13 @@ export type InstanceSettings = { googleAllowSignup: boolean; /** Comma-separated domains a new Google account must be on. Empty is any. */ googleAllowedDomains: string; + /** + * A Microsoft Entra app registration, for members who connect their + * Microsoft 365 mail and calendar. Empty client id means the option is + * not offered. Never used for sign-in. + */ + microsoftClientId: string; + microsoftClientSecret: string; /** Stripe, for the instance owner who hosts other people for a fee. */ stripeSecretKey: string; stripeWebhookSecret: string; @@ -74,7 +83,7 @@ export type InstanceSettings = { */ export type VariableKind = "text" | "url" | "secret" | "toggle"; -export type VariableGroup = "Instance" | "Sign-in" | "Email" | "Billing"; +export type VariableGroup = "Instance" | "Sign-in" | "Accounts" | "Email" | "Billing"; export type VariableDef = { key: string; @@ -189,6 +198,26 @@ export const VARIABLES: VariableDef[] = [ placeholder: "acme.com, acme.co.uk", fallback: "", }, + { + key: SETTING_KEYS.microsoftClientId, + field: "microsoftClientId", + label: "Microsoft client ID", + help: "The Application (client) ID of an app registration in Microsoft Entra. Setting it lets each person connect their Microsoft 365 or Outlook.com mail and calendar under Settings → Connections. It is never used for signing in.", + kind: "text", + group: "Accounts", + placeholder: "00000000-0000-0000-0000-000000000000", + fallback: "", + }, + { + key: SETTING_KEYS.microsoftClientSecret, + field: "microsoftClientSecret", + label: "Microsoft client secret", + help: "A client secret from the same app registration. Stored on your server and never shown again. Entra secrets expire — two years at most — so put the date in your calendar.", + kind: "secret", + group: "Accounts", + placeholder: "", + fallback: "", + }, { key: SETTING_KEYS.resendApiKey, field: "resendApiKey", @@ -292,6 +321,8 @@ export async function getSettings(): Promise { googleClientSecret: raw(SETTING_KEYS.googleClientSecret), googleAllowSignup: raw(SETTING_KEYS.googleAllowSignup) === "1", googleAllowedDomains: raw(SETTING_KEYS.googleAllowedDomains), + microsoftClientId: raw(SETTING_KEYS.microsoftClientId), + microsoftClientSecret: raw(SETTING_KEYS.microsoftClientSecret), stripeSecretKey: raw(SETTING_KEYS.stripeSecretKey), stripeWebhookSecret: raw(SETTING_KEYS.stripeWebhookSecret), stripePaymentLink: raw(SETTING_KEYS.stripePaymentLink), @@ -515,6 +546,11 @@ export function emailIsConfigured(settings: InstanceSettings) { * Both halves or nothing. A client id with no secret would show the button and * fail at the callback, which is a worse failure than no button. */ +/** Whether members can connect a Microsoft 365 account at all. */ +export function microsoftIsConfigured(settings: InstanceSettings) { + return Boolean(settings.microsoftClientId && settings.microsoftClientSecret); +} + export function googleIsConfigured(settings: InstanceSettings) { return Boolean(settings.googleClientId && settings.googleClientSecret); } diff --git a/src/server/actions.ts b/src/server/actions.ts index e7ba01a..0a576ca 100644 --- a/src/server/actions.ts +++ b/src/server/actions.ts @@ -14,7 +14,7 @@ import * as pipelineShare from "@/lib/data/pipeline-share"; import * as users from "@/lib/data/users"; import * as waitlist from "@/lib/data/waitlist"; import * as connections from "@/lib/data/connections"; -import * as google from "@/lib/data/google"; +import * as accounts from "@/lib/data/accounts"; import { authenticate, claimInstance, @@ -401,45 +401,84 @@ export async function unlinkGoogleAction() { } // --------------------------------------------------------------------------- -// Gmail and Calendar +// Mail and calendar accounts // --------------------------------------------------------------------------- /** - * Connecting is a redirect through Google (/api/auth/google?data=1), not an - * action. Disconnecting revokes the token at Google and deletes the row. + * Google and Microsoft connect by a redirect through their consent screens + * (/api/auth/google?data=1, /api/auth/microsoft), not an action. IMAP is a + * form, so it is one. Disconnecting revokes where the provider allows it and + * deletes the row. */ -export async function disconnectGoogleAction() { +export async function connectImapAccountAction(input: accounts.ImapConnectInput) { const user = await requireUser(); - await google.disconnectGoogleAccount(user.id); + try { + const account = await accounts.connectImapAccount(user.id, input); + revalidatePath("/settings"); + return { ok: true as const, account: serialiseAccount(account) }; + } catch (error) { + return { ok: false as const, error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function testAccountAction(accountId: string) { + const user = await requireUser(); + try { + const result = await accounts.testAccount(user.id, accountId); + revalidatePath("/settings"); + return { ok: true as const, mail: result.mail, calendar: result.calendar }; + } catch (error) { + return { ok: false as const, error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function renameAccountAction(accountId: string, label: string) { + const user = await requireUser(); + await accounts.renameLinkedAccount(user.id, accountId, label); + revalidatePath("/settings"); +} + +export async function disconnectAccountAction(accountId: string) { + const user = await requireUser(); + await accounts.disconnectAccount(user.id, accountId); revalidatePath("/settings"); return { ok: true as const }; } +function serialiseAccount(account: accounts.LinkedAccountView) { + return { + ...account, + connectedAt: account.connectedAt.toISOString(), + lastUsedAt: account.lastUsedAt?.toISOString() ?? null, + lastErrorAt: account.lastErrorAt?.toISOString() ?? null, + }; +} + /** * The panels on a contact, company, application or resume. Fetched after * the page renders, because it is a round trip to Google and the page should * not wait on it. Dates go out as ISO strings so the client can format them. */ export async function correspondenceAction( - subject: google.CorrespondenceSubject, + subject: accounts.CorrespondenceSubject, ): Promise< | { ok: true; correspondence: ReturnType } | { ok: false; error: string; notConnected: boolean } > { const user = await requireUser(); try { - const result = await google.listCorrespondence(user.id, subject); + const result = await accounts.listCorrespondence(user.id, subject); return { ok: true, correspondence: serialiseCorrespondence(result) }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error), - notConnected: error instanceof google.GoogleNotConnectedError, + notConnected: error instanceof accounts.AccountNotConnectedError, }; } } -function serialiseCorrespondence(result: google.Correspondence) { +function serialiseCorrespondence(result: accounts.Correspondence) { return { subject: result.subject, terms: result.terms, @@ -463,7 +502,7 @@ function serialiseCorrespondence(result: google.Correspondence) { export async function emailThreadAction(threadId: string) { const user = await requireUser(); try { - const thread = await google.getEmailThread(user.id, threadId); + const thread = await accounts.getEmailThread(user.id, threadId); return { ok: true as const, thread: { @@ -1449,7 +1488,7 @@ export async function getApplicationForPanelAction(id: string) { tags.listTags(user.id, "APPLICATION"), pipeline.listCompanies(user.id), getSettings(), - google.getGoogleConnection(user.id), + accounts.accountAccess(user.id), pipeline.applicationFieldValues(user.id), ]); if (!application) throw new Error("That application is gone."); @@ -1526,9 +1565,7 @@ export async function getApplicationForPanelAction(id: string) { } : null, logos: settings.companyLogos, - googleAccess: googleConnection - ? { mail: googleConnection.mail, calendar: googleConnection.calendar } - : null, + googleAccess: googleConnection, }; } diff --git a/tools/gen-tool-docs.mjs b/tools/gen-tool-docs.mjs index 2a9882d..5b10902 100644 --- a/tools/gen-tool-docs.mjs +++ b/tools/gen-tool-docs.mjs @@ -57,9 +57,9 @@ const SECTIONS = [ { file: "archive.mdx", first: "list_archive", last: "empty_archive", title: "Archive", icon: "trash-can", blurb: "what has been deleted, putting it back, and getting rid of it for good." }, - { file: "google.mdx", first: "get_google_connection", last: "disconnect_google", - title: "Gmail & Calendar", icon: "envelope", - blurb: "the threads and meetings behind any record, read live from your own Google account." }, + { file: "accounts.mdx", first: "list_linked_accounts", last: "search_calendar", + title: "Mail & Calendar", icon: "envelope", + blurb: "the threads and meetings behind any record, read live from your own Google, Microsoft 365 or IMAP and CalDAV accounts." }, { file: "connections.mdx", first: "whoami", last: "delete_connection", title: "Your account", icon: "plug", blurb: "who you are, and the wiring itself." },