# @danj/hey-email ## Intent Let your agents, and the Kody packages they build, search, read, and draft your HEY mail. The package calls HEY at `app.hey.com`, through the same API that HEY's CLI and SDK use. Kody never runs the `hey` CLI. You run it once on your computer, to sign in for Kody. Sending is barred until you allow it. `SENDING` in `src/config.js` is the switch. While it is `'barred'`, no function sends mail, and your agent saves drafts for you to send. The section "The sending switch" explains how to allow sending. The package is publish-locked on Kody. A change goes live only after you review it and click **Promote this commit**. ## What you can ask your agent Ask your agent for any of these: - "Find the invoice from Acme last week and read me its total from the PDF." - "Summarize my thread with Ada." - "Draft a reply to Ada that says the Tuesday slot works." The agent saves a reply draft and gives you its edit link. - "Email Bob that I'm running late." The agent saves a new draft addressed to Bob and gives you its edit link. - "Move this Logos newsletter thread to Paper Trail." The agent moves that one thread. Other mail from the same sender stays where it is. You do these yourself: - Send each draft. Open its edit link in HEY, check the text and the recipients, and send. - Sign in to HEY, and paste your secrets on Kody's setup pages. - Review and promote each change to the package on Kody. - Decide whether sending is ever allowed. ## What it does Each `./name` export imports as `kody:@danj/hey-email/name`. | Export | What it does | HEY calls | | --- | --- | --- | | `kody:@danj/hey-email` | Returns your HEY `{ id, name }`. Use it as a smoke test. | `GET /identity` | | `./search` | Searches your mail. Pass `limit` greater than 10 to fetch further pages. | `GET /advanced_search.json`, then `?page=2` and so on | | `./paper-trail` | Moves one thread to the Paper Trail, by topic id or entry id. | `GET /boxes.json`, then `POST /topics/{id}/moves.json` | | `./boxes` | Lists your boxes, such as the Imbox and the Feed. | `GET /boxes.json` | | `./topic` | Reads one thread. | `GET /topics/{id}.json` | | `./entries` | Lists the messages in a thread. | `GET /topics/{id}/entries.json` | | `./message` | Reads one message with its HTML body. | `GET /messages/{id}.json` | | `./attachments` | Lists PDF attachments on a message, then downloads one file as base64. The default export is `listPdfs`: metadata only, no `bytes`. | `GET /messages/{id}.json` to list. One `GET` for a chosen PDF. | | `./drafts` | Lists, reads, and saves drafts with their recipients. HEY keeps each saved draft as a draft. It also exports the three send functions. | `GET /entries/drafts.json`, `GET /messages/{id}/edit.json`, `POST /messages.json`, `POST /entries/{id}/replies.json` | | `./reply` | Saves a reply draft on a thread. | `GET /entries/{id}/replies/new.json`, `POST /entries/{id}/replies.json` | | `./send` | `sendMessage`, `sendReply`, and `sendDraft`. | None while sending is barred. While it is allowed, a confirmed send calls `POST /messages.json`, `POST /entries/{id}/replies.json`, or `PUT /messages/{id}.json`. | `AGENTS.md` lists each function's inputs, results, and errors. The default export of `./attachments` is `listPdfs`. It returns metadata only (`index`, `filename`, `contentType`, `url`, `filesize`) and has no `bytes`. There is no combined download of every file. Call `downloadPdf` for one PDF as base64. ## Set it up You do the steps that need your HEY sign-in, your secrets, or your review. Your agent does the rest. Your secret values never pass through the agent. 1. You need a HEY account. HEY is a paid 37signals product with a free trial at [hey.com](https://hey.com). A `@hey.com` address and a HEY for Domains address both work. 2. You install the HEY CLI on your computer, version 1.4.0 or later. Follow the [hey-cli install guide](https://github.com/basecamp/hey-cli/blob/main/docs/install.md). 3. You sign in to HEY for Kody, in a new folder. To HEY, Kody is then a separate device, like a second computer. Your own CLI login stays as it is. Run these lines in one terminal window, and keep that window for steps 5 and 7. ```bash HEY_KODY=$(mktemp -d) XDG_CONFIG_HOME="$HEY_KODY" HEY_NO_KEYRING=1 hey auth login XDG_CONFIG_HOME="$HEY_KODY" HEY_NO_KEYRING=1 hey auth status ``` `hey auth login` opens HEY's sign-in page in your browser. `HEY_NO_KEYRING` makes the CLI save this login in the folder, not in your keychain. In the status, the `Auth:` line must show `oauth`, and the `Storage:` line must show `file`. The `Token:` line shows the token cut short with `...`, so do not copy it from there. Step 5 copies the full values. Do not add `--cookie` or `--token` to the login. Those logins store no refresh token, so this package cannot use them. HEY can email you about a sign-in from a new device. 4. Your agent sends you three Kody setup links, one for each secret. Each link fills in the secret's name, its description, and the allowed host `app.hey.com`. The `heyToken` and `heyRefreshToken` links also ask you to let this package update them, so it can save new tokens after a refresh. 5. You copy each value from the folder and paste it on its link. Each command copies one value to the clipboard. The first two need `jq`, which macOS 15 and later include. - `heyToken`: `jq -j '."https://app.hey.com".access_token' "$HEY_KODY/hey-cli/credentials.json" | pbcopy` - `heyRefreshToken`: `jq -j '."https://app.hey.com".refresh_token' "$HEY_KODY/hey-cli/credentials.json" | pbcopy` - `heyInstallId`: `tr -d '\n' < "$HEY_KODY/hey-cli/install_id" | pbcopy` After the last paste, clear the clipboard with `pbcopy `. In a fork of this package, use your own scope instead of `@danj`. 2. Read `AGENTS.md` for each function's inputs, results, and errors. 3. Your package bundles a snapshot of this one. After each change here, republish your package. ## The sending switch `SENDING` in `src/config.js` is the switch. Its committed value is `'barred'`. Only you decide to change it. | | `SENDING = 'barred'` (committed) | `SENDING = 'allowed'` | | --- | --- | --- | | `sendMessage`, `sendReply`, `sendDraft` | Return the barred result, with `barred: true` and `sent: false`. No request reaches HEY. | Send when `confirm` is exactly `true`. Otherwise, return a dry run, and send nothing. | | `createDraft`, `createReplyDraft` | Save a draft with its recipients. HEY does not send it. | Same. | An agent must not change `SENDING` to finish an email request. While sending is allowed, an agent still saves a draft, unless you ask it in plain words to send now. An agent must not send from an unattended run, such as a reminder job. The code does not enforce these two rules, so the lock on Kody matters. ### Allowing sending 1. You change `SENDING` to `'allowed'`. You can commit to main, or open a pull request. You can also ask your agent in plain words to make this change. 2. Three test files and the test hook pin the barred value. The same commit updates `test/sending-gate.test.js`, `test/sending-allowed.test.js`, `test/redirects.test.js`, and `test/hooks.mjs`. It also updates the two package descriptions in `package.json`. 3. The `sending-gate` check fails on a branch or pull request that sets `'allowed'` while main is still `'barred'`. So a pull request with this change shows a failed check. You can still merge it. After that, later branches carry `'allowed'` from main, and the check passes them. 4. Your agent publishes the package to Kody. You review the change on the package page, and click **Promote this commit**. Then your agent republishes the packages that use this one. ## Security posture | Secret | What it holds | The package sends it to | The package updates it | | --- | --- | --- | --- | | `heyToken` | The access token. It expires after about 14 days. | `app.hey.com` | After each refresh | | `heyRefreshToken` | The refresh token | `app.hey.com/oauth/tokens` | When HEY returns a new one | | `heyInstallId` | The install id of Kody's login. HEY ties the refresh token to it. | `app.hey.com/oauth/tokens` | Never | The package sends `{{secret:...}}` placeholders. Kody fills them in only for the host `app.hey.com`, on any path. What this design guarantees: - Credentials go only to `app.hey.com`. `resolveHeyUrl` refuses every other origin. - The package follows redirects itself. A redirect off `app.hey.com` drops the token. PDF downloads continue to the file storage host as a plain `GET`. JSON calls refuse the redirect. Token refresh refuses every redirect. - On a `HEY 401` from `app.hey.com`, the package refreshes the token once and retries. A 401 from the PDF storage host does not start a refresh. Before the package spends the refresh token, it checks that it can save the new pair. It saves both tokens in one batch, and retries the save once. - No function sends mail while `SENDING` is `'barred'`. - The package is publish-locked. No change, including a change to `SENDING`, goes live on Kody before you promote it. What it leaves open: - Any code that imports this package can read all your mail and save drafts. While `SENDING` is `'allowed'`, that code can also send mail. - During a refresh, the package code holds the new tokens in memory until Kody saves them. - GitHub refuses branch protection on this private repository on the free plan. So the `sending-gate` check does not block a merge or a push to main. The lock on Kody is the control that holds. ## Troubleshooting | Error | Cause | Fix | | --- | --- | --- | | `HEY 401 ` | HEY refused the new token after a successful refresh. | Run setup steps 3 to 7 again. | | `HEY token refresh failed (HTTP )` | HEY refused the refresh token. A wrong `heyInstallId`, or a token from a `--cookie` or `--token` login, gives the same error. | Run setup steps 3 to 7 again. | | `HEY token refresh aborted: this package cannot write heyToken / heyRefreshToken` | The package has no grant to update the two secrets. | Ask your agent for the approval link. | | Error code `HEY_TOKEN_ROTATION_UNSAVED` | HEY issued new tokens, but Kody did not save them. The saved refresh token no longer works. | Run setup steps 3 to 7 again. | | Error code `HEY_TOKEN_PERSIST_UNSUPPORTED` | The Kody runtime lacks `kody.secretSetMany`. | Report it to Kody. | | `Refusing to send HEY credentials to ` | A URL outside `app.hey.com` reached the package, such as a PDF link in an email. `downloadPdf` stops there and returns no file. | Open the file in HEY. | | `Refusing to follow HEY redirect to ` | HEY redirected a JSON call off `app.hey.com`. | None. The package refuses it. | | Kody says the host is not approved | `app.hey.com` is not an allowed host of the secret. | Ask your agent for the host approval link. | | `listPdfs` returns no PDFs | The HTML came from the entries list, which omits attachments. | Pass the message id. | ## Development The package has no dependencies. `npm test` runs the Node test runner. The test hooks in `test/hooks.mjs` stub `kody:runtime`, and they load a second copy of `src/` where `SENDING` is `'allowed'`. The committed file stays `'barred'`. The `sending-gate` GitHub Actions workflow runs on Node 22, on pull requests and on pushes to every branch except main. It runs `scripts/check-sending-policy.mjs`, then `npm test`. The script fails when the branch sets `SENDING` to `'allowed'` and `origin/main` does not. For your own terminal, use the HEY CLI directly. It also ships an agent skill and an MCP server, described in the [hey-cli agent guide](https://github.com/basecamp/hey-cli/blob/main/docs/agents.md). ## Credits and license This package is a fork of `@noah/hey-email` on Kody, by Noah (HiThereHey). Noah wrote the first HEY API wrapper, the draft and send functions, and the token refresh. Daniel Jomphe's fork adds the redirect rules, safer token saving, the sending switch, the HEY SDK request format, and the separate Kody login. MIT. `LICENSE` keeps Noah's copyright notice, and adds Daniel Jomphe's for the fork's changes.