AkurAI Build
Menu

Public repository / 3af23b0e937520000e1aa43c7e2eb9de2675abd1

AkurAI-Mail / src/lib/webmail/compose.js

Latest change 3af23b0e937520000e1aa43c7e2eb9de2675abd1 - OLI-248: webmail list and reader as framework islands on a signals store by Ólafur Búi Ólafsson

/**
 * @typedef {{ name: string, type: string, size: number, data: string }} ComposeAttachment
 * @typedef {{ to: string, cc: string, bcc: string, subject: string, body: string, bodyText?: string, attachments: ComposeAttachment[] }} ComposeState  `body` is the editor's HTML, `bodyText` its plain-text rendering.
 */
/** Compose helpers: drafts kept across re-authentication, quoting, send scheduling. */

/** @typedef {{ getItem(key: string): string | null, setItem(key: string, value: string): void, removeItem(key: string): void }} DraftStorage */

export const DRAFT_RECOVERY_KEY = "akurai-mail-draft-recovery";
export const UNDO_SEND_SECONDS = 10;
/** Marks where quoted content starts in a reply/forward body. */
export const QUOTE_MARKER = "<!--akurai-quote-->";

/**
 * @returns {ComposeState}
 */
export function emptyCompose() {
  return { to: "", cc: "", bcc: "", subject: "", body: "", bodyText: "", attachments: [] };
}

/**
 * @param {ComposeState} draft
 */
export function hasDraftContent(draft) {
  return !!(draft.to || draft.cc || draft.bcc || draft.subject || draft.body || draft.attachments?.length);
}

/**
 * Saves the draft; drops attachments if storage quota is exceeded. Returns false if nothing could be saved.
 * @param {DraftStorage} storage
 * @param {string} key
 * @param {ComposeState} draft
 */
export function stashDraft(storage, key, draft) {
  try {
    storage.setItem(key, JSON.stringify(draft));
    return true;
  } catch {
    try {
      storage.setItem(key, JSON.stringify({ ...draft, attachments: [] }));
      return true;
    } catch {
      return false;
    }
  }
}

/**
 * Reads and removes a stashed draft.
 * @param {DraftStorage} storage
 * @param {string} key
 * @returns {ComposeState | null}
 */
export function takeDraft(storage, key) {
  const raw = storage.getItem(key);
  if (!raw) return null;
  storage.removeItem(key);
  try {
    return JSON.parse(raw);
  } catch {
    return null;
  }
}

/**
 * @param {string} value
 */
export function escapeHtml(value) {
  return value.replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch] ?? ch);
}

/** @typedef {{ date: string, from: string, subject: string, bodyText?: string }} QuotedMessage */

/**
 * @param {string} text
 */
function htmlLines(text) {
  return escapeHtml(text).replaceAll("\n", "<br>");
}

/**
 * @param {QuotedMessage} message
 */
export function replyBody(message) {
  return `<br><br>${QUOTE_MARKER}On ${message.date}, ${message.from} wrote:<blockquote>${htmlLines(message.bodyText ?? "")}</blockquote>`;
}

/**
 * @param {QuotedMessage} message
 */
export function forwardBody(message) {
  return `<br><br>${QUOTE_MARKER}Forwarded message:<br>From: ${escapeHtml(message.from)}<br>Date: ${escapeHtml(message.date)}<br>Subject: ${escapeHtml(message.subject)}<br><br>${htmlLines(message.bodyText ?? "")}`;
}

/**
 * Parses a datetime-local value to unix seconds, or returns an error message.
 * @param {string} value
 * @param {number} [now]
 * @returns {{ sendAt: number, error?: undefined } | { error: string, sendAt?: undefined }}
 */
export function parseSendAt(value, now = Date.now()) {
  if (!value) return { error: "Pick a send time first" };
  const sendAt = Math.floor(new Date(value).getTime() / 1000);
  if (!Number.isFinite(sendAt) || sendAt <= now / 1000) return { error: "Send time must be in the future" };
  return { sendAt };
}

/**
 * Seconds left before a held (undo-able) send is dispatched.
 * @param {number} sendAt
 * @param {number} [now]
 */
export function secondsUntil(sendAt, now = Date.now()) {
  return Math.max(0, sendAt - Math.floor(now / 1000));
}