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.

src/drafts.js

374 lines · 12.4 KB · JavaScript
Raw
import { heyFetch, heyRequest, BASE } from './client.js'
import { decideSend } from './config.js'

const BARRED_MESSAGE =
  "Sending is barred. Leave SENDING as 'barred'. confirm: true does nothing until that literal is 'allowed'. Save a draft."

const JSON_HEADERS = { 'Content-Type': 'application/json' }

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 emails(value) {
  return [].concat(value ?? []).map(String).filter(Boolean)
}

function recipientsFrom(input) {
  return { to: emails(input.to), cc: emails(input.cc), bcc: emails(input.bcc) }
}

function addressesOf(contacts) {
  return (contacts || []).map((c) => c.email_address).filter(Boolean)
}

function contactsOf(contacts) {
  return (contacts || []).map((c) => ({ name: c.name, email: c.email_address }))
}

function draftBody({ actingSenderId, subject, content, recipients }) {
  const addressed = {}
  if (recipients.to.length) addressed.directly = recipients.to
  if (recipients.cc.length) addressed.copied = recipients.cc
  if (recipients.bcc.length) addressed.blindcopied = recipients.bcc
  return {
    acting_sender_id: actingSenderId,
    message: { subject, content },
    entry: { status: 'drafted', addressed },
  }
}

async function defaultSenderId() {
  const identity = await heyFetch('/identity.json')
  const senders = identity?.senders || []
  const id = (senders.find((s) => s.default) || senders[0])?.id || identity?.primary_contact?.id
  if (!id) throw new Error('HEY identity names no sender')
  return id
}

async function replySenderId(prefill) {
  return prefill?.sender?.id || defaultSenderId()
}

// HEY appends the quoted original itself, so the prefill's quote is not echoed back.
function replyContent(input, prefill) {
  return input.html || `${textToHtml(input.text)}${prefill?.creator?.name_tag || ''}`
}

function draftIdFromLocation(location) {
  const retry = 'Check listDrafts() before you save it again.'
  if (!location) throw new Error(`HEY saved the draft but sent no Location, so its id is unknown. ${retry}`)
  const last = new URL(location, BASE).pathname.split('/').filter(Boolean).at(-1) || ''
  if (!/^[1-9]\d*$/.test(last)) {
    throw new Error(`HEY saved the draft but its Location ${location} names no id. ${retry}`)
  }
  return Number(last)
}

async function saveDraft(path, message) {
  const { status, headers } = await heyRequest(path, {
    method: 'POST',
    headers: JSON_HEADERS,
    body: JSON.stringify(draftBody(message)),
  })
  const id = draftIdFromLocation(headers.get('location'))
  return {
    id,
    status,
    subject: message.subject,
    url: `${BASE}/messages/${id}`,
    edit_url: `${BASE}/messages/${id}/edit`,
    ...message.recipients,
  }
}

function slimDraft(d) {
  if (!d || typeof d !== 'object') return d
  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.sender?.id || d.creator?.id || d.acting_sender_id,
    to: contactsOf(d.addressed_contacts || d.addressed?.directly),
    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:@danj/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.
 *
 * Without `to`, `cc`, or `bcc`, the draft goes to the thread's recipients
 * from the prefill. Passing any of them replaces those.
 *
 * @param {{
 *   entryId?: number|string,
 *   topicId?: number|string,
 *   text?: string,
 *   html?: string,
 *   subject?: string,
 *   to?: string|string[],
 *   cc?: string|string[],
 *   bcc?: string|string[],
 * }} input
 * @returns {Promise<{ id: number, status: number, subject: string, url: string, edit_url: string, topic_url: string|null, to: string[], cc: string[], bcc: string[] }>}
 *
 * @example
 * import { createReplyDraft } from 'kody:@danj/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. It saves a draft. Leave SENDING as 'barred'.")
  }
  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 thread = prefill?.addressed || {}
  const recipients =
    input.to != null || input.cc != null || input.bcc != null
      ? recipientsFrom(input)
      : {
          to: addressesOf(thread.directly),
          cc: addressesOf(thread.copied),
          bcc: addressesOf(thread.blindcopied),
        }

  const draft = await saveDraft(`/entries/${entryId}/replies.json`, {
    actingSenderId: await replySenderId(prefill),
    subject: input.subject || prefill?.subject || '',
    content: replyContent(input, prefill),
    recipients,
  })
  return { ...draft, topic_url: input.topicId ? `${BASE}/topics/${input.topicId}` : null }
}

/**
 * Save a new (non-reply) HEY draft. Never sends.
 *
 * The draft acts as the account's default sender.
 *
 * @param {{ subject?: string, text?: string, html?: string, to?: string|string[], cc?: string|string[], bcc?: string|string[] }} input
 * @returns {Promise<{ id: number, status: number, subject: string, url: string, edit_url: string, to: string[], cc: string[], bcc: string[] }>}
 */
export async function createDraft(input = {}) {
  if (input.send || input.confirmSend) {
    throw new Error("createDraft will not send. It saves a draft. Leave SENDING as 'barred'.")
  }
  return saveDraft('/messages.json', {
    actingSenderId: await defaultSenderId(),
    subject: input.subject || '',
    content: input.html || textToHtml(input.text),
    recipients: recipientsFrom(input),
  })
}

/**
 * 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),
    cc: contactsOf(data.addressed?.copied),
    bcc: contactsOf(data.addressed?.blindcopied),
    content: data.content,
  }
}

function barredSend(extra = {}) {
  return {
    barred: true,
    dryRun: true,
    wouldSend: false,
    sent: false,
    reason: 'sending-barred',
    message: BARRED_MESSAGE,
    ...extra,
  }
}

async function deliver(path, { method, confirm, ...message }) {
  if (decideSend(confirm).effect !== 'confirmed') {
    throw new Error('refusing to write a delivering body without a confirmed send')
  }
  const body = draftBody(message)
  delete body.entry.status
  return heyRequest(path, { method, headers: JSON_HEADERS, body: JSON.stringify(body) })
}

/**
 * Send an existing draft.
 * decideSend runs before any fetch. While SENDING is 'barred', this returns
 * the barred result and does not GET the draft. confirm: true does nothing
 * until SENDING is exactly 'allowed'. The PUT replaces the draft's subject,
 * body, and recipients with what it carries, so it keeps the draft's cc and
 * bcc.
 *
 * @param {{ id: number|string, confirm?: boolean, to?: string|string[] }} input
 */
export async function sendDraft(input = {}) {
  const id = input.id
  if (id == null) throw new Error('id is required')
  const decision = decideSend(input.confirm)
  if (decision.effect === 'barred') return barredSend()
  const draft = await getDraft({ id })
  const to = input.to != null ? emails(input.to) : draft.to.map((c) => c.email).filter(Boolean)
  if (decision.effect === 'unconfirmed') {
    return { dryRun: true, wouldSend: true, to, draft }
  }
  if (!to.length) throw new Error('sendDraft needs at least one to address')
  const { data } = await deliver(`/messages/${id}.json`, {
    method: 'PUT',
    confirm: input.confirm,
    actingSenderId: draft.acting_sender_id,
    subject: draft.subject,
    content: draft.content,
    recipients: {
      to,
      cc: draft.cc.map((c) => c.email).filter(Boolean),
      bcc: draft.bcc.map((c) => c.email).filter(Boolean),
    },
  })
  return { dryRun: false, sent: true, to, raw: data }
}

/**
 * Send a new HEY email.
 * Missing `to` or `subject` throws before the gate. While SENDING is 'barred',
 * confirm: true returns the barred result and does not fetch. Save a draft
 * instead of flipping SENDING.
 *
 * @param {{
 *   to: string|string[],
 *   subject: string,
 *   text?: string,
 *   html?: string,
 *   cc?: string|string[],
 *   bcc?: string|string[],
 *   confirm?: boolean,
 * }} input
 *
 * @example
 * import { sendMessage } from 'kody:@danj/hey-email/drafts'
 * await sendMessage({ to: 'someone@example.com', subject: 'On my way', text: 'Leaving now.', confirm: true })
 * // barred while SENDING is 'barred'; no request to app.hey.com
 */
export async function sendMessage(input = {}) {
  const to = emails(input.to)
  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')
  const decision = decideSend(input.confirm)
  if (decision.effect === 'barred') return barredSend({ to, subject, content })
  if (decision.effect === 'unconfirmed') {
    return { dryRun: true, wouldSend: true, to, subject, content }
  }
  const { status, headers, data } = await deliver('/messages.json', {
    method: 'POST',
    confirm: input.confirm,
    actingSenderId: await defaultSenderId(),
    subject,
    content,
    recipients: recipientsFrom(input),
  })
  const loc = 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.
 * decideSend runs before prefill. While SENDING is 'barred', this returns the
 * barred result and does not fetch. confirm: true does nothing until SENDING
 * is exactly 'allowed'.
 *
 * @param {{ entryId?: number|string, topicId?: number|string, text?: string, html?: string, subject?: string, confirm?: boolean }} input
 */
export async function sendReply(input = {}) {
  const decision = decideSend(input.confirm)
  if (decision.effect === 'barred') return barredSend()
  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 = addressesOf(prefill?.addressed?.directly)
  const content = replyContent(input, prefill)
  const subject = input.subject || prefill?.subject || ''
  if (decision.effect === 'unconfirmed') {
    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 deliver(`/entries/${entryId}/replies.json`, {
    method: 'POST',
    confirm: input.confirm,
    actingSenderId: await replySenderId(prefill),
    subject,
    content,
    recipients: { to, cc: [], bcc: [] },
  })
  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()
}