← Public packages
@noah/hey-email
HEY.com email integration with Kody: search threads, read messages/attachments, create drafts, and send with explicit confirmation.
src/drafts.js
294 lines · 9.4 KB · JavaScriptimport { heyFetch, heyRequest, BASE } from './client.js'
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
}
function textToHtml(text) {
const raw = String(text || '').trim()
if (!raw) return ''
return raw
.split(/\n{2,}/)
.map((p) => `<div>${escapeHtml(p).replace(/\n/g, '<br>')}</div>`)
.join('<div><br></div>')
}
function extractIdFromLocation(loc) {
if (!loc) return null
const m = String(loc).match(/\/messages\/(\d+)/)
return m ? Number(m[1]) : null
}
function slimDraft(d) {
if (!d || typeof d !== 'object') return d
const to = (d.addressed_contacts || d.addressed?.directly || []).map((c) => ({
name: c.name,
email: c.email_address,
}))
return {
id: d.id,
subject: d.subject || '',
summary: d.summary || '',
updated_at: d.updated_at,
is_reply: Boolean(d.is_reply),
acting_sender_id: d.creator?.id || d.acting_sender_id,
to,
url: d.app_url || d.url || (d.id ? `${BASE}/messages/${d.id}` : null),
edit_url: d.edit_url || (d.id ? `${BASE}/messages/${d.id}/edit` : null),
}
}
/**
* List HEY drafts.
* Read-only.
*
* @returns {Promise<Array<{id:number, subject:string, summary:string, url:string, edit_url:string}>>}
*
* @example
* import { listDrafts } from 'kody:@noah/hey-email/drafts'
* const drafts = await listDrafts()
*/
export async function listDrafts() {
const data = await heyFetch('/entries/drafts.json')
return (Array.isArray(data) ? data : []).map(slimDraft)
}
/**
* Prefill a reply (subject, quote, recipients, acting sender). Does not write.
*
* @param {{ entryId: number|string, topicId?: number|string }} input
*/
export async function getReplyPrefill(input) {
let entryId = input?.entryId
if (entryId == null && input?.topicId != null) {
const entries = await heyFetch(`/topics/${input.topicId}/entries.json`)
const first = Array.isArray(entries) ? entries[0] : null
entryId = first?.id
}
if (entryId == null) throw new Error('entryId is required')
return await heyFetch(`/entries/${entryId}/replies/new.json`)
}
/**
* Save a reply as a HEY draft. Never sends.
*
* HEY treats POST /entries/{id}/replies.json as a send when `to` is present
* and as a draft when `to` is omitted. This helper never sends `to`.
*
* @param {{
* entryId?: number|string,
* topicId?: number|string,
* text?: string,
* html?: string,
* subject?: string,
* }} input
* @returns {Promise<{ id: number|null, subject: string, url: string, edit_url: string, topic_url?: string }>}
*
* @example
* import { createReplyDraft } from 'kody:@noah/hey-email/drafts'
* const draft = await createReplyDraft({ topicId: 123456789, text: 'Thanks, sounds good.' })
*/
export async function createReplyDraft(input = {}) {
if (input.send || input.confirmSend) {
throw new Error('createReplyDraft will not send. Use sendDraft({ id, confirm: true }) after you review.')
}
const prefill = await getReplyPrefill(input)
const entryId = input.entryId || prefill?.url?.match(/\/entries\/(\d+)/)?.[1]
if (entryId == null) throw new Error('entryId is required')
const nameTag = prefill?.creator?.name_tag || ''
const quote = prefill?.content || ''
const bodyHtml = input.html
? input.html
: `${textToHtml(input.text)}${nameTag}${quote}`
const payload = {
acting_sender_id: prefill?.creator?.id,
subject: input.subject || prefill?.subject || '',
content: bodyHtml,
}
const { status, headers, data } = await heyRequest(`/entries/${entryId}/replies.json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const loc = headers.get('location') || headers.get('Location')
const id = extractIdFromLocation(loc) || data?.id || null
const topicMatch = String(prefill?.url || '').match(/topics\/(\d+)/)
return {
id,
status,
subject: payload.subject,
url: id ? `${BASE}/messages/${id}` : loc,
edit_url: id ? `${BASE}/messages/${id}/edit` : null,
topic_url: input.topicId ? `${BASE}/topics/${input.topicId}` : null,
to: (prefill?.addressed?.directly || []).map((c) => ({
name: c.name,
email: c.email_address,
})),
notice: data?.notice || null,
}
}
/**
* Save a new (non-reply) HEY draft. Never sends — `to` is omitted on purpose.
*
* @param {{ subject?: string, text?: string, html?: string }} input
*/
export async function createDraft(input = {}) {
if (input.send || input.confirmSend) {
throw new Error('createDraft will not send. Use sendDraft({ id, confirm: true }) after you review.')
}
const payload = {
subject: input.subject || '',
content: input.html || textToHtml(input.text),
}
const { status, headers, data } = await heyRequest('/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const loc = headers.get('location') || headers.get('Location')
const id = extractIdFromLocation(loc) || data?.id || null
return {
id,
status,
subject: payload.subject,
url: id ? `${BASE}/messages/${id}` : loc,
edit_url: id ? `${BASE}/messages/${id}/edit` : null,
notice: data?.notice || null,
}
}
/**
* Read one draft / message by id.
* @param {{ id: number|string }} input
*/
export async function getDraft(input) {
const id = input?.id
if (id == null) throw new Error('id is required')
const data = await heyFetch(`/messages/${id}/edit.json`)
return { ...slimDraft(data), content: data.content }
}
/**
* Send an existing draft. Requires confirm: true. Default is dry-run.
* HEY sends when PUT /messages/{id}.json includes `to`.
*
* @param {{ id: number|string, confirm?: boolean, to?: string[] }} input
*/
export async function sendDraft(input = {}) {
const id = input.id
if (id == null) throw new Error('id is required')
const draft = await getDraft({ id })
const to = input.to || (draft.to || []).map((c) => c.email).filter(Boolean)
if (input.confirm !== true) {
return { dryRun: true, wouldSend: true, to, draft }
}
if (!to.length) throw new Error('sendDraft needs at least one to address')
const { data } = await heyRequest(`/messages/${id}.json`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
acting_sender_id: draft.acting_sender_id,
subject: draft.subject,
content: draft.content,
to,
}),
})
return { dryRun: false, sent: true, to, raw: data }
}
/**
* Send a new HEY email. Requires confirm: true. Default is dry-run.
* Use this when the user explicitly says to email someone.
*
* @param {{
* to: string|string[],
* subject: string,
* text?: string,
* html?: string,
* cc?: string|string[],
* confirm?: boolean,
* }} input
*
* @example
* import { sendMessage } from 'kody:@noah/hey-email/drafts'
* await sendMessage({ to: 'someone@example.com', subject: 'On my way', text: 'Leaving now.', confirm: true })
*/
export async function sendMessage(input = {}) {
const to = [].concat(input.to || []).map(String).filter(Boolean)
const subject = input.subject || ''
const content = input.html || textToHtml(input.text)
if (!to.length) throw new Error('to is required')
if (!subject) throw new Error('subject is required')
if (input.confirm !== true) {
return { dryRun: true, wouldSend: true, to, subject, content }
}
const blank = await heyFetch('/messages/new.json')
const payload = {
acting_sender_id: blank?.creator?.id,
subject,
content,
to,
}
if (input.cc) payload.cc = [].concat(input.cc)
const { status, headers, data } = await heyRequest('/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const loc = headers.get('location') || headers.get('Location')
return {
dryRun: false,
sent: data?.notice === 'sent' || status === 200,
to,
subject,
url: loc || null,
notice: data?.notice || null,
raw: data,
}
}
/**
* Send a reply on a thread. Requires confirm: true. Default is dry-run.
*
* @param {{ entryId?: number|string, topicId?: number|string, text?: string, html?: string, confirm?: boolean }} input
*/
export async function sendReply(input = {}) {
const prefill = await getReplyPrefill(input)
const entryId = input.entryId || prefill?.url?.match(/\/entries\/(\d+)/)?.[1]
if (entryId == null) throw new Error('entryId is required')
const to = (prefill?.addressed?.directly || []).map((c) => c.email_address).filter(Boolean)
const nameTag = prefill?.creator?.name_tag || ''
const quote = prefill?.content || ''
const content = input.html ? input.html : `${textToHtml(input.text)}${nameTag}${quote}`
const subject = input.subject || prefill?.subject || ''
if (input.confirm !== true) {
return { dryRun: true, wouldSend: true, to, subject, content }
}
if (!to.length) throw new Error('sendReply has no recipients from the thread prefill')
const { status, data } = await heyRequest(`/entries/${entryId}/replies.json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
acting_sender_id: prefill?.creator?.id,
subject,
content,
to,
}),
})
return { dryRun: false, sent: data?.notice === 'sent' || status === 200, to, subject, notice: data?.notice || null, raw: data }
}
/**
* Default export: list drafts (read-only).
*/
export default async function drafts() {
return listDrafts()
}