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, '>')
}
function textToHtml(text) {
const raw = String(text || '').trim()
if (!raw) return ''
return raw
.split(/\n{2,}/)
.map((p) => `
${escapeHtml(p).replace(/\n/g, '
')}
`)
.join('
')
}
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>}
*
* @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()
}