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