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