Menu
Public repository / 7a6a24a4e46f9f1d964c5593c076f9bdf638fea0
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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));
}