Skip to content
← Public packages

@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
Raw

@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" }).

SecretQuery
heyTokenname=heyToken&description=HEY%20access%20token&allowedHosts=app.hey.com&allowedPackages=<package id>&scope=user
heyRefreshTokenname=heyRefreshToken&description=HEY%20refresh%20token&allowedHosts=app.hey.com&allowedPackages=<package id>&scope=user
heyInstallIdname=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

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

FunctionInputResult
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()NoneHEY's array
getTopic({ topicId })topicIdHEY's JSON
getEntries({ topicId })topicIdArray, or []
getMessage({ messageId })messageId, or entryId as an aliasHEY'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 })idThe listDrafts shape plus cc, bcc, and content
getReplyPrefill(input)entryId, or topicIdHEY'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, confirmSee "Send results"
sendReply(input)entryId or topicId, text or html, subject, confirmSee "Send results"
sendDraft(input)id, to, confirmSee "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 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 <filename> and no PDF at index <n> mean the message has no matching PDF. multiple PDFs named <filename>; 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 <status> <path> is a failed JSON call. HEY binary <status> <path> 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 <path>, or HEY binary 401 <path> for a PDF.
  • Refusing to send HEY credentials to <origin> and Refusing to follow HEY redirect to <origin> 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

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.