@danj/hey-email
HEY.com email for Kody: search threads, read messages, download PDF attachments, and write drafts. Sending is barred until you allow it.
AGENTS.md
221 lines · 12.0 KB · Markdown@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
SENDINGonly 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
createDraftorcreateReplyDraft, and give the user itsedit_url. Pass the recipients asto,cc, orbcc. The draft keeps them, and HEY does not send it. The user checks the draft and sends it in HEY. - While
SENDINGis'allowed', still save a draft. Call a send function withconfirm: trueonly 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 }. Checkresult.barred.sendMessageaddsto,subject, andcontent. - 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-setlink, and wait until the user says it is saved. - To reply to a specific message, pass its
entryId. With onlytopicId, 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
listPdfsordownloadPdf. - 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=<package id>&scope=user |
heyRefreshToken | name=heyRefreshToken&description=HEY%20refresh%20token&allowedHosts=app.hey.com&allowedPackages=<package id>&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.secretLockinexecute. It returns the approval link. Send it to the user. - When Kody says
app.hey.comis not approved for a secret, send/connect/secrets?names=heyToken,heyRefreshToken,heyInstallId&hosts=app.hey.comon the same origin. - When the user confirms the secrets are saved, run
identity().
Imports
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:<value>. 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 <div> 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
SENDINGis'barred', each send function returns the barred result, and makes no request. - While
SENDINGis'allowed'andconfirmis not exactlytrue, it returns a dry run withdryRun: trueandwouldSend: true, and sends nothing.sendReplyandsendDraftread from HEY for it. - After a send, it returns
{ dryRun: false, sent, to, raw }.sendMessageandsendReplyaddsubjectandnotice.sendMessageaddsurl. sendReplysends only to the thread's direct recipients. It drops the thread's cc and bcc.sendDraftuses the draft's recipients, ortowhen you pass it. It keeps the draft's cc and bcc.
Errors
createDraftandcreateReplyDraftthrow whensendorconfirmSendis truthy.HEY saved the draft but ...means HEY saved the draft and named no id in itsLocationheader. CalllistDrafts()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, andsubject is required. downloadPdfrefuses when the serialized return (metadata plus base64) would exceedmaxEncodedBytes. The error names that full size. It always refuses when the raw size is over 24 MiB (24 * 1024 * 1024bytes). It never truncates.no PDF named <filename>andno PDF at index <n>mean the message has no matching PDF.multiple PDFs named <filename>; pass index to choose onemeans two attachments share that name.message names no topicmeans the message forentryIdhas notopic_id.HEY lists no Paper Trail boxmeans/boxes.jsonhas no box of kindtrailbox.sendDraft needs at least one to addressandsendReply has no recipients from the thread prefillmean the send has no recipient.HEY identity names no sendermeans/identity.jsonlists no sender.HEY <status> <path>is a failed JSON call.HEY binary <status> <path>is a failed PDF download.- When
app.hey.comanswers 401 to a request that carries the token, the package refreshes the token once and retries. A second 401 throwsHEY 401 <path>, orHEY binary 401 <path>for a PDF. Refusing to send HEY credentials to <origin>andRefusing to follow HEY redirect to <origin>mean a URL or a redirect leftapp.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.jsonwithentry.status: 'drafted'. - Reply draft:
GET /entries/{id}/replies/new.jsonfor the prefill, thenPOST /entries/{id}/replies.jsonwithentry.status: 'drafted'. entry.status: 'drafted'keeps a message a draft, with or without recipients. HEY delivers a body without it. Only the internaldeliverfunction writes such a body, and only for a confirmed send whileSENDINGis'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_idis the reply prefill'ssender.idwhen HEY names one. Otherwise it is the default sender inGET /identity.json, then the first sender, thenprimary_contact.id. A draft send keeps the draft's sender.- HEY answers a draft save with a 2xx status, usually
204with no body. The draft's id is the last segment of theLocationheader. - 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. Passpageonly when it is greater than 1. The HTML search pages return 406. - Move one topic to the Paper Trail:
GET /boxes.jsonfor the box whosekindistrailbox, thenPOST /topics/{topicId}/moves.jsonwith{ box_id }. That is the official SDK's MoveTopic. It does not call/postings/moves,/clearances, or/contacts.
Smoke test
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.