# @danj/hey-email agent notes This package reads and drafts the user's HEY mail at `app.hey.com`. It never sends mail while `SENDING` in `src/config.js` is `'barred'`. Setup, the sending switch, and the security posture live in `README.md`. ## Rules - Change `SENDING` only when the user asks in plain words to allow sending in this package. A request to email someone is not that ask. - When the user asks you to email someone, save a draft with `createDraft` or `createReplyDraft`, and give the user its `edit_url`. Pass the recipients as `to`, `cc`, or `bcc`. The draft keeps them, and HEY does not send it. The user checks the draft and sends it in HEY. - While `SENDING` is `'allowed'`, still save a draft. Call a send function with `confirm: true` only when the user asks in plain words to send now. - A barred send makes no request and returns `{ barred: true, dryRun: true, wouldSend: false, sent: false, reason: 'sending-barred', message }`. Check `result.barred`. `sendMessage` adds `to`, `subject`, and `content`. - Never send from an unattended run, such as a reminder job. The code does not enforce this rule. - Never ask the user for a secret value in chat. To set or replace a secret, send a `/connect/secret-set` link, and wait until the user says it is saved. - To reply to a specific message, pass its `entryId`. With only `topicId`, the package uses the first entry that HEY lists for the topic. That entry can be the oldest message. - The entries list omits attachment markup. Pass a message id to `listPdfs` or `downloadPdf`. - In `README.md`, link only to absolute URLs outside Kody user scopes. Kody shows relative links and `/@…` paths as plain text, so name packages and files in code. ## Publishing The package is publish-locked on Kody. After you push and publish, the change waits until the user promotes it. Send the user `https://kody.codes/@danj/hey-email/approve-publish`, on the user's Kody origin. Wait until the user says the change is promoted, then run the smoke test. You can lock a package with `kody.packageUpdate`, but you cannot unlock it. ## Secret setup links Send one link per secret. The package id of `@danj/hey-email` is `0d573ca6-b2ea-4d04-a6d2-fe20e44be719`. In a fork, read it from `search({ entity: "package:hey-email" })`. | Secret | Query | | --- | --- | | `heyToken` | `name=heyToken&description=HEY%20access%20token&allowedHosts=app.hey.com&allowedPackages=&scope=user` | | `heyRefreshToken` | `name=heyRefreshToken&description=HEY%20refresh%20token&allowedHosts=app.hey.com&allowedPackages=&scope=user` | | `heyInstallId` | `name=heyInstallId&description=HEY%20CLI%20install%20id&allowedHosts=app.hey.com&scope=user` | Prefix each query with the user's Kody origin and `/connect/secret-set?`, such as `https://kody.codes/connect/secret-set?`. The package updates `heyToken` and `heyRefreshToken` after a refresh, so those two need the package grant. It only reads `heyInstallId`. - When an error says the package cannot write the tokens, call `kody.secretLock` in `execute`. It returns the approval link. Send it to the user. - When Kody says `app.hey.com` is not approved for a secret, send `/connect/secrets?names=heyToken,heyRefreshToken,heyInstallId&hosts=app.hey.com` on the same origin. - When the user confirms the secrets are saved, run `identity()`. ## Imports ```ts import identity from 'kody:@danj/hey-email' import search from 'kody:@danj/hey-email/search' import moveToPaperTrail from 'kody:@danj/hey-email/paper-trail' import listBoxes from 'kody:@danj/hey-email/boxes' import getTopic from 'kody:@danj/hey-email/topic' import getEntries from 'kody:@danj/hey-email/entries' import getMessage from 'kody:@danj/hey-email/message' import listPdfs, { downloadPdf } from 'kody:@danj/hey-email/attachments' import { listDrafts, getDraft, createDraft, createReplyDraft, getReplyPrefill } from 'kody:@danj/hey-email/drafts' import { sendMessage, sendReply, sendDraft } from 'kody:@danj/hey-email/send' ``` `./drafts` also exports the three send functions. Its default export, `drafts()`, returns `listDrafts()`. `./reply` exports `createReplyDraft` as its default, and `getReplyPrefill`. `./send` exports `sendMessage` as its default. `./paper-trail` exports `moveToPaperTrail` as its default. `./attachments` also exports `getMessage`, `parseTrixAttachments`, `parseActionTextAttachments`, and `parseRailsPdfUrls`. Its default export is `listPdfs`, which returns metadata only and has no `bytes`. There is no combined download of every file. ## Functions | Function | Input | Result | | --- | --- | --- | | `identity()` | None | `{ id, name }` | | `search(input)` | `q` or `query`, `from`, `date` (such as `'last_7_days'`), `in`, `page`, `limit`. Other keys pass through as query parameters. `from` is added to `q` as `from:`. `page` is 1-based; 1 omits the param. `limit` past 10 fetches `page=2` and further pages. | `{ matches, raw }` | | `moveToPaperTrail(input)` | `topicId`, or `entryId` (reads `topic_id` from the message). Moves that one topic to the Paper Trail. Does not change sender screening. | `{ topicId, boxId, status, url }` | | `listBoxes()` | None | HEY's array | | `getTopic({ topicId })` | `topicId` | HEY's JSON | | `getEntries({ topicId })` | `topicId` | Array, or `[]` | | `getMessage({ messageId })` | `messageId`, or `entryId` as an alias | HEY's JSON, with the HTML `content` | | `listPdfs(input)` | `messageId`, `entryId`, `content`, or `message: { content }` | `[{ index, filename, contentType, url, filesize }]`. Metadata only. Does not download bodies. | | `downloadPdf(input)` | Same as `listPdfs`, plus `filename` or `index`, and `maxEncodedBytes` (the execute `responseLimit` for the whole returned value). | `{ filename, contentType, url, filesize, base64 }`. One file. `base64` is a string. Never a `Uint8Array`. | | `listDrafts()` | None | `[{ id, subject, summary, updated_at, is_reply, acting_sender_id, to, url, edit_url }]` | | `getDraft({ id })` | `id` | The `listDrafts` shape plus `cc`, `bcc`, and `content` | | `getReplyPrefill(input)` | `entryId`, or `topicId` | HEY's prefill: subject, quote, recipients, and sender | | `createReplyDraft(input)` | `entryId` or `topicId`, `text` or `html`, `subject`, `to`, `cc`, `bcc` | `{ id, status, subject, url, edit_url, topic_url, to, cc, bcc }` | | `createDraft(input)` | `subject`, `text` or `html`, `to`, `cc`, `bcc` | `{ id, status, subject, url, edit_url, to, cc, bcc }` | | `sendMessage(input)` | `to`, `subject`, `text` or `html`, `cc`, `bcc`, `confirm` | See "Send results" | | `sendReply(input)` | `entryId` or `topicId`, `text` or `html`, `subject`, `confirm` | See "Send results" | | `sendDraft(input)` | `id`, `to`, `confirm` | See "Send results" | In `listDrafts` and `getDraft`, `to`, `cc`, and `bcc` are arrays of `{ name, email }`. In the results of a draft save, they are arrays of addresses. `status` is HEY's HTTP status. `topic_url` is null unless you pass `topicId`. `text` becomes `
` blocks, one per paragraph. For a reply, the package adds the name tag from the prefill's `creator` after `text`. `html` replaces the whole body, with no name tag. The package never adds the quoted message. HEY adds it when it sends the reply. `to`, `cc`, and `bcc` take one address or an array. A reply draft without them goes to the thread's recipients from the prefill. Pass any of them to replace all three lists. A reply draft with only `cc` has no `to`. ### Send results - While `SENDING` is `'barred'`, each send function returns the barred result, and makes no request. - While `SENDING` is `'allowed'` and `confirm` is not exactly `true`, it returns a dry run with `dryRun: true` and `wouldSend: true`, and sends nothing. `sendReply` and `sendDraft` read from HEY for it. - After a send, it returns `{ dryRun: false, sent, to, raw }`. `sendMessage` and `sendReply` add `subject` and `notice`. `sendMessage` adds `url`. - `sendReply` sends only to the thread's direct recipients. It drops the thread's cc and bcc. - `sendDraft` uses the draft's recipients, or `to` when you pass it. It keeps the draft's cc and bcc. ## Errors - `createDraft` and `createReplyDraft` throw when `send` or `confirmSend` is truthy. - `HEY saved the draft but ...` means HEY saved the draft and named no id in its `Location` header. Call `listDrafts()` before you save it again. - A missing input throws before any request. The messages are `topicId is required`, `messageId is required`, `entryId is required`, `topicId or entryId is required`, `id is required`, `messageId or content is required`, `filename or index is required`, `index must be a non-negative integer`, `maxEncodedBytes is required`, `to is required`, and `subject is required`. - `downloadPdf` refuses when the serialized return (metadata plus base64) would exceed `maxEncodedBytes`. The error names that full size. It always refuses when the raw size is over 24 MiB (`24 * 1024 * 1024` bytes). It never truncates. - `no PDF named ` and `no PDF at index ` mean the message has no matching PDF. `multiple PDFs named ; pass index to choose one` means two attachments share that name. - `message names no topic` means the message for `entryId` has no `topic_id`. - `HEY lists no Paper Trail box` means `/boxes.json` has no box of kind `trailbox`. - `sendDraft needs at least one to address` and `sendReply has no recipients from the thread prefill` mean the send has no recipient. - `HEY identity names no sender` means `/identity.json` lists no sender. - `HEY ` is a failed JSON call. `HEY binary ` is a failed PDF download. - When `app.hey.com` answers 401 to a request that carries the token, the package refreshes the token once and retries. A second 401 throws `HEY 401 `, or `HEY binary 401 ` for a PDF. - `Refusing to send HEY credentials to ` and `Refusing to follow HEY redirect to ` mean a URL or a redirect left `app.hey.com`. - Refresh errors and their fixes are in the README's Troubleshooting table. For each fix that says to run setup again, send the user new setup links. ## HEY endpoints Every write uses the body of the official Go SDK, `basecamp/hey-sdk` at tag `go/v0.32.1`: `{ acting_sender_id, message: { subject, content }, entry: { status, addressed: { directly, copied, blindcopied } } }`. `addressed` holds only the kinds with an address. - Draft: `POST /messages.json` with `entry.status: 'drafted'`. - Reply draft: `GET /entries/{id}/replies/new.json` for the prefill, then `POST /entries/{id}/replies.json` with `entry.status: 'drafted'`. - `entry.status: 'drafted'` keeps a message a draft, with or without recipients. HEY delivers a body without it. Only the internal `deliver` function writes such a body, and only for a confirmed send while `SENDING` is `'allowed'`. - Send: `POST /messages.json`. Reply send: `POST /entries/{id}/replies.json`. Draft send: `PUT /messages/{id}.json`. The PUT replaces the draft's subject, body, and recipients. - `acting_sender_id` is the reply prefill's `sender.id` when HEY names one. Otherwise it is the default sender in `GET /identity.json`, then the first sender, then `primary_contact.id`. A draft send keeps the draft's sender. - HEY answers a draft save with a 2xx status, usually `204` with no body. The draft's id is the last segment of the `Location` header. - List drafts: `GET /entries/drafts.json`. Read a draft: `GET /messages/{id}/edit.json`. - Edit URL: `https://app.hey.com/messages/{id}/edit`. Topic URL: `https://app.hey.com/topics/{id}`. - Search: `GET /advanced_search.json`. Pass `page` only when it is greater than 1. The HTML search pages return 406. - Move one topic to the Paper Trail: `GET /boxes.json` for the box whose `kind` is `trailbox`, then `POST /topics/{topicId}/moves.json` with `{ box_id }`. That is the official SDK's MoveTopic. It does not call `/postings/moves`, `/clearances`, or `/contacts`. ## Smoke test ```ts await identity() await search({ q: 'receipt', date: 'last_7_days' }) await listDrafts() ``` ## Local checks `npm test`. CI runs it on Node 22, for pull requests and for pushes to branches other than main.