AkurAI Build
Menu

Public repository / 0216cf0f6008aae88a648a82e9b3a462a6688302

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

Latest change 22202d1bde69be4ace8a299dffd91f8ff24378df - Recover deployed webmail frontend (frontend parity with EC2 a6afffc) by Ólafur Búi Ólafsson

/** Compose helpers: drafts kept across re-authentication, quoting, send scheduling. */

export interface ComposeAttachment { name: string; type: string; size: number; data: string }

export interface ComposeState {
  to: string;
  cc: string;
  bcc: string;
  subject: string;
  /** Rich-text HTML from the editor. */
  body: string;
  /** Plain-text rendering of `body`. */
  bodyText?: string;
  attachments: ComposeAttachment[];
}

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-->";

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

export function hasDraftContent(draft: ComposeState) {
  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. */
export function stashDraft(storage: Storage, key: string, draft: ComposeState) {
  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. */
export function takeDraft(storage: Storage, key: string): ComposeState | null {
  const raw = storage.getItem(key);
  if (!raw) return null;
  storage.removeItem(key);
  try {
    return JSON.parse(raw);
  } catch {
    return null;
  }
}

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

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

interface QuotedMessage { date: string; from: string; subject: string; bodyText?: string }

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

export function forwardBody(message: QuotedMessage) {
  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. */
export function parseSendAt(value: string, now = Date.now()): { sendAt: number } | { error: string } {
  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. */
export function secondsUntil(sendAt: number, now = Date.now()) {
  return Math.max(0, sendAt - Math.floor(now / 1000));
}