Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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 · JavaScript
import { heyFetch, heyRequest, BASE } from './client.js'

function escapeHtml(s) {
  return String(s || '')
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
}

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()
}