Files
ihasmail-inbuxa/web/src/store/compose.ts
T
jcoffey-dev 52299ce8ef Attach a file that is already in Files
Attaching meant uploading, even when the file was sitting in the account
already -- picking it off disk again to send the server a copy of what it
was holding.

The composer can now attach from Files. A blob the account can already
see needs no upload at all: an attachment carrying a `blobId` is what a
forward produces, so the send path has always known what to do with one.
Attaching a large file the server is already storing now costs nothing
and takes no time.

A file in an account somebody *shared* is different, because blobs belong
to the account they were uploaded to and a draft in yours cannot
reference one in theirs. Those are fetched and uploaded to your account,
and the picker says so before you attach rather than leaving someone
wondering why one file was instant and another was not.

The picker borrows the Files store, so it browses what Files browses,
shared accounts included, and puts the file manager back where it was on
the way out -- a detour through somebody's shared folder to find an
attachment should not leave Files somewhere else afterwards.

Verified against the mock, and worth recording how, because the first
attempt measured nothing: `client.upload` uses XMLHttpRequest, since it
reports progress, so a counter wrapped around `fetch` sees no uploads
whether or not any happen and agrees with you either way. Counted at
XHR instead: attaching one's own file issues no upload, and attaching a
shared one issues exactly one, to the reader's own account.
2026-08-27 10:18:49 -07:00

810 lines
36 KiB
TypeScript

import { create } from "zustand";
import { client, setErrorMessage } from "@/jmap/client";
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
import { formatFullDate, uid } from "@/lib/format";
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text";
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html";
import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { settings } from "./settings";
export interface ComposeAttachment {
id: string;
name: string;
type: string;
size: number;
blobId: Id | null;
progress: number;
error: string | null;
file?: File;
cid?: string;
inline?: boolean;
abort?: AbortController;
}
/** A file in Files, enough of it to attach. */
export interface AttachableFile {
accountId: Id;
name: string;
type: string | null;
size: number | null;
blobId: Id;
}
export type Priority = "high" | "normal" | "low";
export interface Draft {
key: string;
draftId: Id | null;
identityId: Id | null;
to: EmailAddress[];
cc: EmailAddress[];
bcc: EmailAddress[];
/** Per-message Reply-To (defaults to the identity's Reply-To). */
replyTo: EmailAddress[];
subject: string;
html: string;
text: string;
format: "html" | "text";
attachments: ComposeAttachment[];
inReplyTo: string[] | null;
references: string[] | null;
relatedEmailId: Id | null;
relatedKeyword: "$answered" | "$forwarded" | null;
requestReceipt: boolean;
priority: Priority;
showCc: boolean;
showBcc: boolean;
showReplyTo: boolean;
minimized: boolean;
maximized: boolean;
dirty: boolean;
savedAt: number | null;
saving: boolean;
sending: boolean;
error: string | null;
/** Original identity signature HTML currently embedded, to replace on identity switch. */
signatureHtml: string;
replyMode: "reply" | "replyAll" | "forward" | null;
mailboxIdOnSend?: Id | null;
/** When set, hand the message to the server held until this instant. */
sendAt: number | null;
}
interface ComposeState {
drafts: Draft[];
activeKey: string | null;
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft }>;
open(init?: Partial<Draft>): string;
openDraftEmail(email: Email): Promise<string>;
reply(email: Email, mode: "reply" | "replyAll" | "forward", opts?: { all?: boolean }): Promise<string>;
update(key: string, patch: Partial<Draft>): void;
close(key: string, opts?: { discard?: boolean }): Promise<void>;
focus(key: string): void;
addFiles(key: string, files: File[]): void;
/** Attach files already in Files, by reference where the account allows it. */
addFromFiles(key: string, nodes: AttachableFile[]): Promise<void>;
removeAttachment(key: string, attId: string): void;
saveDraft(key: string, opts?: { silent?: boolean }): Promise<Id | null>;
send(key: string): Promise<void>;
undoSend(key: string): void;
setIdentity(key: string, identityId: Id): void;
insertTemplate(key: string, html: string, subject?: string): void;
}
const AUTOSAVE_MS = 20_000;
const autosaveTimers = new Map<string, number>();
function blankDraft(init: Partial<Draft> = {}): Draft {
const s = settings();
return {
key: uid("d"),
draftId: null,
identityId: null,
to: [],
cc: [],
bcc: [],
replyTo: [],
subject: "",
html: "",
text: "",
format: s.composeFormat,
attachments: [],
inReplyTo: null,
references: null,
relatedEmailId: null,
relatedKeyword: null,
requestReceipt: s.requestReadReceipt,
priority: "normal",
showCc: false,
showBcc: false,
showReplyTo: false,
minimized: false,
maximized: false,
dirty: false,
savedAt: null,
saving: false,
sending: false,
error: null,
signatureHtml: "",
replyMode: null,
sendAt: null,
...init,
};
}
export function signatureBlock(identity: Identity | undefined, format: "html" | "text"): string {
if (!identity) return "";
if (format === "text") return identity.textSignature ? `\n\n-- \n${identity.textSignature}` : "";
if (identity.htmlSignature) return `<div class="ihm-signature" data-ihm-sig="1"><br>${sanitizeEditorHtml(identity.htmlSignature)}</div>`;
if (identity.textSignature) return `<div class="ihm-signature" data-ihm-sig="1"><br>-- <br>${textToHtml(identity.textSignature, { quoteColors: false }).replace(/\n/g, "<br>")}</div>`;
return "";
}
function defaultIdentity(identities: Identity[], email?: Email | null): Identity | undefined {
if (!identities.length) return undefined;
if (email) {
const candidates = [...(email.to ?? []), ...(email.cc ?? []), ...(email.bcc ?? [])];
for (const c of candidates) {
const m = identities.find((i) => sameAddress(i.email, c.email));
if (m) return m;
}
}
return useMail.getState().defaultIdentity() ?? identities[0];
}
export const useCompose = create<ComposeState>((set, get) => ({
drafts: [],
activeKey: null,
pendingSends: {},
open(init = {}) {
const identities = useMail.getState().identities;
const ident = init.identityId ? identities.find((i) => i.id === init.identityId) : useMail.getState().defaultIdentity();
const d = blankDraft({ identityId: ident?.id ?? null, replyTo: ident?.replyTo ?? [], showReplyTo: Boolean(ident?.replyTo?.length), ...init });
if (!init.html && !init.text && ident) {
d.signatureHtml = signatureBlock(ident, "html");
d.html = `<div><br></div>${d.signatureHtml}`;
d.text = signatureBlock(ident, "text");
}
set((s) => ({ drafts: [...s.drafts.map((x) => ({ ...x, minimized: s.drafts.length >= 1 ? x.minimized : x.minimized })), d], activeKey: d.key }));
return d.key;
},
async openDraftEmail(email) {
const existing = get().drafts.find((d) => d.draftId === email.id);
if (existing) {
get().focus(existing.key);
return existing.key;
}
const full = (await useMail.getState().getEmails([email.id], true))[0] ?? email;
const identities = useMail.getState().identities;
const ident = identities.find((i) => full.from?.some((f) => sameAddress(f.email, i.email))) ?? useMail.getState().defaultIdentity() ?? identities[0];
const htmlPart = full.htmlBody?.[0];
const textPart = full.textBody?.[0];
const html = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
const text = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
const accountId = useMail.getState().accountId!;
const cidMap: Record<string, string> = {};
const attachments: ComposeAttachment[] = [];
for (const a of full.attachments ?? []) {
const inline = Boolean(a.cid) && (a.disposition === "inline" || a.type.startsWith("image/"));
if (inline && a.cid && a.blobId) cidMap[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
}
const d = blankDraft({
draftId: full.id,
identityId: ident?.id ?? null,
to: full.to ?? [],
cc: full.cc ?? [],
bcc: full.bcc ?? [],
replyTo: full.replyTo ?? ident?.replyTo ?? [],
showReplyTo: Boolean(full.replyTo?.length || ident?.replyTo?.length),
showCc: Boolean(full.cc?.length),
showBcc: Boolean(full.bcc?.length),
subject: full.subject ?? "",
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
text: text || (html ? htmlToText(html) : ""),
format: html ? "html" : settings().composeFormat,
attachments,
inReplyTo: full.inReplyTo ?? null,
references: full.references ?? null,
requestReceipt: Boolean(full["header:Disposition-Notification-To:asAddresses"]?.length),
priority: /^[12]/.test(full["header:X-Priority:asText"] ?? "") ? "high" : /^[45]/.test(full["header:X-Priority:asText"] ?? "") ? "low" : "normal",
});
set((s) => ({ drafts: [...s.drafts, d], activeKey: d.key }));
return d.key;
},
async reply(email, mode) {
const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email;
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
const ident = defaultIdentity(identities, full);
const ownEmails = identities.map((i) => i.email.toLowerCase());
const isOwn = (a: EmailAddress) => ownEmails.includes(a.email.toLowerCase());
const s = settings();
let to: EmailAddress[] = [];
let cc: EmailAddress[] = [];
if (mode === "reply" || mode === "replyAll") {
const replyTo = full.replyTo?.length ? full.replyTo : (full.from ?? []);
to = uniqueAddresses(replyTo);
if (mode === "replyAll") {
const others = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]).filter((a) => !isOwn(a) && !to.some((t) => sameAddress(t.email, a.email)));
cc = others;
// If the message was sent by me, reply to original recipients instead.
if (to.every(isOwn) && full.to?.length) {
to = uniqueAddresses(full.to);
cc = uniqueAddresses(full.cc ?? []).filter((a) => !isOwn(a));
}
} else if (to.every(isOwn) && full.to?.length) {
to = uniqueAddresses(full.to.filter((a) => !isOwn(a)));
if (!to.length) to = uniqueAddresses(full.to);
}
}
const htmlPart = full.htmlBody?.[0];
const textPart = full.textBody?.[0];
const origHtml = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
const origText = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
const accountId = mail.accountId!;
const attachments: ComposeAttachment[] = [];
const cidMap: Record<string, string> = {};
for (const a of full.attachments ?? []) {
const inline = Boolean(a.cid) && a.type.startsWith("image/");
if (inline && a.cid && a.blobId) cidMap[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
if (mode === "forward" || inline) {
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
}
}
// Inline images are shown via their blob URLs in the editor and converted back to cid: at send time.
const quotedHtmlBody = origHtml
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false }).html
: textToHtml(origText).replace(/\n/g, "<br>");
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
const date = formatFullDate(full.receivedAt);
let quoteHtml = "";
let quoteTxt = "";
if (mode === "forward") {
const hdr = [
`From: ${(full.from ?? []).map(formatAddress).join(", ")}`,
`Date: ${date}`,
`Subject: ${full.subject ?? ""}`,
`To: ${(full.to ?? []).map(formatAddress).join(", ")}`,
...(full.cc?.length ? [`Cc: ${full.cc.map(formatAddress).join(", ")}`] : []),
];
quoteHtml = `<div class="ihm-quote"><br><div>---------- Forwarded message ---------</div><div>${hdr.map(escapeHtml).join("<br>")}</div><br>${quotedHtmlBody}</div>`;
quoteTxt = `\n\n---------- Forwarded message ---------\n${hdr.join("\n")}\n\n${origText || (origHtml ? htmlToText(origHtml) : "")}`;
} else if (s.includeQuote) {
quoteHtml = `<div class="ihm-quote"><br><div>On ${escapeHtml(date)}, ${fromStr} wrote:</div><blockquote style="margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex">${quotedHtmlBody}</blockquote></div>`;
quoteTxt = `\n\nOn ${date}, ${(full.from ?? []).map(formatAddress).join(", ")} wrote:\n${quoteTextOf(origText, origHtml)}`;
}
const sigHtml = signatureBlock(ident, "html");
const sigText = signatureBlock(ident, "text");
const html = s.signatureAboveQuote ? `<div><br></div>${sigHtml}${quoteHtml}` : `<div><br></div>${quoteHtml}${sigHtml}`;
const text = s.signatureAboveQuote ? `${sigText}${quoteTxt}` : `${quoteTxt}${sigText}`;
const messageId = full.messageId?.[0];
const d = blankDraft({
identityId: ident?.id ?? null,
to,
cc,
showCc: cc.length > 0,
replyTo: ident?.replyTo ?? [],
showReplyTo: Boolean(ident?.replyTo?.length),
subject: replySubject(full.subject, mode === "forward" ? "Fwd" : "Re"),
html,
text,
format: s.composeFormat,
attachments,
inReplyTo: mode === "forward" ? null : messageId ? [messageId] : null,
references: mode === "forward" ? null : messageId ? [...(full.references ?? []), messageId] : (full.references ?? null),
relatedEmailId: full.id,
relatedKeyword: mode === "forward" ? "$forwarded" : "$answered",
signatureHtml: sigHtml,
replyMode: mode,
});
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
return d.key;
},
update(key, patch) {
set((s) => ({ drafts: s.drafts.map((d) => (d.key === key ? { ...d, ...patch, dirty: patch.dirty ?? (d.dirty || isContentPatch(patch)) } : d)) }));
if (isContentPatch(patch)) scheduleAutosave(key, get);
},
async close(key, opts = {}) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const t = autosaveTimers.get(key);
if (t) window.clearTimeout(t);
autosaveTimers.delete(key);
for (const a of d.attachments) a.abort?.abort();
set((s) => ({ drafts: s.drafts.filter((x) => x.key !== key), activeKey: s.activeKey === key ? (s.drafts.find((x) => x.key !== key)?.key ?? null) : s.activeKey }));
if (opts.discard) {
if (d.draftId) {
try {
await client.call("Email/set", { accountId: useMail.getState().accountId, destroy: [d.draftId] });
void useMail.getState().refreshList();
void useMail.getState().loadMailboxes();
} catch {
/* ignore */
}
}
toast.show("Draft discarded");
return;
}
if (d.dirty && (d.to.length || d.subject || hasContent(d))) {
try {
await saveDraftInternal(d, get, set, { silent: true, final: true });
toast.show("Draft saved");
} catch (err) {
toast.error(`Could not save draft: ${(err as Error).message}`);
}
}
},
focus(key) {
set((s) => ({ activeKey: key, drafts: s.drafts.map((d) => (d.key === key ? { ...d, minimized: false } : d)) }));
},
addFiles(key, files) {
const accountId = useMail.getState().accountId;
if (!accountId) return;
const max = client.maxSizeUpload;
const atts: ComposeAttachment[] = files.map((f) => ({ id: uid("a"), name: f.name, type: f.type || "application/octet-stream", size: f.size, blobId: null, progress: 0, error: f.size > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null, file: f }));
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
for (const a of atts) {
if (a.error || !a.file) continue;
const abort = new AbortController();
a.abort = abort;
client
.upload(accountId, a.file, {
type: a.type,
signal: abort.signal,
onProgress: (loaded, total) => patchAtt(key, a.id, { progress: Math.round((loaded / total) * 100) }, set),
})
.then((res) => patchAtt(key, a.id, { blobId: res.blobId, progress: 100, type: res.type || a.type, size: res.size }, set))
.catch((err) => patchAtt(key, a.id, { error: (err as Error).message || "Upload failed" }, set));
}
},
/*
* Attach something already in Files.
*
* A blob the account can already see needs no upload: an attachment carrying
* a `blobId` is exactly what a forward produces, so the send path already
* knows what to do with one. Attaching a 20 MB file the server is holding
* anyway then costs nothing and takes no time.
*
* A file in an account somebody *shared* is a different matter. Blobs belong
* to the account they were uploaded to, so a draft in your account cannot
* reference one in theirs; it is fetched and uploaded to yours. Slower, and
* unavoidable, but it happens without the reader having to know any of this.
*/
async addFromFiles(key, nodes) {
const accountId = useMail.getState().accountId;
if (!accountId || !nodes.length) return;
const max = client.maxSizeUpload;
const atts: ComposeAttachment[] = nodes.map((n) => ({
id: uid("a"),
name: n.name,
type: n.type || "application/octet-stream",
size: n.size ?? 0,
blobId: n.accountId === accountId ? n.blobId : null,
progress: n.accountId === accountId ? 100 : 0,
error: (n.size ?? 0) > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null,
}));
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
for (const [i, a] of atts.entries()) {
if (a.error || a.blobId) continue;
const node = nodes[i]!;
try {
const blob = await client.fetchBlob(node.accountId, node.blobId, a.type);
const up = await client.upload(accountId, blob, { type: a.type });
patchAtt(key, a.id, { blobId: up.blobId, progress: 100, size: up.size || a.size }, set);
} catch (err) {
patchAtt(key, a.id, { error: (err as Error).message || "Could not attach" }, set);
}
}
},
removeAttachment(key, attId) {
const d = get().drafts.find((x) => x.key === key);
const a = d?.attachments.find((x) => x.id === attId);
a?.abort?.abort();
get().update(key, { attachments: (d?.attachments ?? []).filter((x) => x.id !== attId) });
},
async saveDraft(key, opts = {}) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return null;
try {
return await saveDraftInternal(d, get, set, { silent: opts.silent ?? false });
} catch (err) {
if (!opts.silent) toast.error(`Could not save draft: ${(err as Error).message}`);
return null;
}
},
async send(key) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const delay = settings().undoSendSeconds;
// A schedule the user left sitting until it passed is just a send now.
const scheduling = d.sendAt !== null && d.sendAt > Date.now();
// Hide the composer immediately; actually send after the undo window.
const t = autosaveTimers.get(key);
if (t) window.clearTimeout(t);
autosaveTimers.delete(key);
set((s) => ({ drafts: s.drafts.filter((x) => x.key !== key), activeKey: s.activeKey === key ? null : s.activeKey }));
const doSend = async () => {
set((s) => {
const { [key]: _drop, ...rest } = s.pendingSends;
return { pendingSends: rest };
});
try {
await sendInternal(d, get);
toast.success(scheduling ? `Send scheduled for ${formatScheduleTime(new Date(d.sendAt!))}` : "Message sent");
} catch (err) {
toast.error(`Send failed: ${(err as Error).message}`, {
action: { label: "Open draft", onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
duration: 15000,
});
}
};
// A scheduled send is already delayed, and cancelling it is a server-side
// operation from the Scheduled folder -- holding it locally first would
// only add a second, different kind of undo.
if (delay <= 0 || scheduling) {
await doSend();
return;
}
const toastId = toast.show("Sending…", { duration: delay * 1000, progress: true, action: { label: "Undo", onClick: () => get().undoSend(key) } });
const timer = window.setTimeout(() => void doSend(), delay * 1000);
set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d } } }));
},
undoSend(key) {
const p = get().pendingSends[key];
if (!p) return;
window.clearTimeout(p.timer);
toast.dismiss(p.toastId);
set((s) => {
const { [key]: _drop, ...rest } = s.pendingSends;
return { pendingSends: rest, drafts: [...s.drafts, { ...p.draft, sending: false }], activeKey: key };
});
},
setIdentity(key, identityId) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const ident = useMail.getState().identities.find((i) => i.id === identityId);
const newSig = signatureBlock(ident, "html");
let html = d.html;
if (d.signatureHtml && html.includes(d.signatureHtml)) html = html.replace(d.signatureHtml, newSig);
else if (!d.signatureHtml && newSig) {
// insert before quote if any, else append
const idx = html.indexOf('<div class="ihm-quote">');
html = idx >= 0 ? html.slice(0, idx) + newSig + html.slice(idx) : html + newSig;
}
// Plain text: replace trailing signature block
const oldSigText = signatureBlock(useMail.getState().identities.find((i) => i.id === d.identityId), "text");
let text = d.text;
if (oldSigText && text.includes(oldSigText)) text = text.replace(oldSigText, signatureBlock(ident, "text"));
const oldIdent = useMail.getState().identities.find((i) => i.id === d.identityId);
const sameList = (a: EmailAddress[], b: EmailAddress[]) => a.length === b.length && a.every((x, i) => sameAddress(x.email, b[i]?.email));
const replyToPatch = sameList(d.replyTo, oldIdent?.replyTo ?? []) ? { replyTo: ident?.replyTo ?? [], showReplyTo: d.showReplyTo || Boolean(ident?.replyTo?.length) } : {};
get().update(key, { identityId, html, text, signatureHtml: newSig, ...replyToPatch });
},
insertTemplate(key, html, subject) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(html)}</div>${d.html}`, text: `${htmlToText(html)}\n${d.text}` };
if (subject && !d.subject) patch.subject = subject;
get().update(key, patch);
},
}));
function quoteTextOf(text: string, html: string): string {
const base = text || (html ? htmlToText(html) : "");
return quoteText(base);
}
function isContentPatch(p: Partial<Draft>): boolean {
return ["to", "cc", "bcc", "replyTo", "subject", "html", "text", "attachments", "identityId", "format", "priority", "requestReceipt"].some((k) => k in p);
}
function hasContent(d: Draft): boolean {
const body = d.format === "html" ? htmlToText(d.html.replace(/<div class="ihm-quote">[\s\S]*$/, "")) : d.text;
return body.replace(/--\s*[\s\S]*$/, "").trim().length > 0 || d.attachments.length > 0;
}
function patchAtt(key: string, attId: string, patch: Partial<ComposeAttachment>, set: (fn: (s: ComposeState) => Partial<ComposeState>) => void) {
set((s) => ({ drafts: s.drafts.map((d) => (d.key === key ? { ...d, dirty: true, attachments: d.attachments.map((a) => (a.id === attId ? { ...a, ...patch } : a)) } : d)) }));
}
function scheduleAutosave(key: string, get: () => ComposeState) {
const t = autosaveTimers.get(key);
if (t) window.clearTimeout(t);
autosaveTimers.set(
key,
window.setTimeout(() => {
autosaveTimers.delete(key);
const d = get().drafts.find((x) => x.key === key);
if (d && d.dirty && !d.sending && (d.to.length || d.subject || hasContent(d))) void get().saveDraft(key, { silent: true });
}, AUTOSAVE_MS),
);
}
/** Build the JMAP Email creation object from a draft. */
export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailboxId?: Id | null }): Promise<Record<string, unknown>> {
const mail = useMail.getState();
const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
if (!ident) throw new Error("No sending identity available");
const from: EmailAddress = { name: ident.name || null, email: ident.email };
let html = d.format === "html" ? d.html : "";
const text = d.format === "html" ? htmlToText(d.html) : d.text;
// Inline attachments shown via blob URLs in the editor → back to cid: references.
for (const a of d.attachments) {
if (a.inline && a.cid && a.blobId && html) {
const re = new RegExp(`/api/blob/[^"' )]*${a.blobId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^"' )]*`, "g");
html = html.replace(re, `cid:${a.cid}`);
}
}
// Inline images (data: URLs from the editor) → upload and reference by cid.
const related: EmailBodyPart[] = [];
const relatedInline: Array<{ blobId: Id; type: string; name: string; cid: string }> = [];
// Images referencing stored blobs (e.g. signature logos kept in Files) → inline cid parts.
if (html && html.includes("/api/blob/")) {
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of Array.from(doc.querySelectorAll("img"))) {
const src = img.getAttribute("src") ?? "";
const m = /^\/api\/blob\/([^/]+)\/([^/]+)\/([^?]+)(?:\?([^#]*))?/.exec(src);
if (!m) continue;
const blobId = decodeURIComponent(m[2]!);
const name = decodeURIComponent(m[3]!);
const type = new URLSearchParams(m[4] ?? "").get("accept") ?? "image/png";
const cid = `${uid("img")}@ihasmail`;
img.setAttribute("src", `cid:${cid}`);
relatedInline.push({ blobId, type, name, cid });
}
html = doc.body.innerHTML;
}
if (html && html.includes("data:image/")) {
const doc = new DOMParser().parseFromString(html, "text/html");
const imgs = Array.from(doc.querySelectorAll("img")).filter((i) => i.getAttribute("src")?.startsWith("data:image/"));
for (const img of imgs) {
const src = img.getAttribute("src")!;
const m = /^data:(image\/[\w.+-]+);base64,(.*)$/s.exec(src);
if (!m) continue;
const bin = atob(m[2]!);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const up = await client.upload(accountId, new Blob([bytes], { type: m[1]! }), { type: m[1]! });
const cid = `${uid("img")}@ihasmail`;
img.setAttribute("src", `cid:${cid}`);
relatedInline.push({ blobId: up.blobId, type: m[1]!, name: `image.${m[1]!.split("/")[1]?.replace("jpeg", "jpg") ?? "png"}`, cid });
}
html = doc.body.innerHTML;
}
// Existing inline attachments referenced via cid (from reply/forward/draft) stay as related parts.
for (const a of d.attachments) {
if (a.inline && a.cid && a.blobId && html.includes(`cid:${a.cid}`)) relatedInline.push({ blobId: a.blobId, type: a.type, name: a.name, cid: a.cid });
}
for (const r of relatedInline) {
related.push({ partId: null, blobId: r.blobId, size: 0, name: r.name, type: r.type, charset: null, disposition: "inline", cid: r.cid });
}
const bodyValues: Record<string, { value: string }> = {};
const alternative: Record<string, unknown>[] = [];
bodyValues.text = { value: text };
alternative.push({ partId: "text", type: "text/plain" });
if (html) {
bodyValues.html = { value: wrapHtmlDocument(html) };
const htmlPart: Record<string, unknown> = { partId: "html", type: "text/html" };
if (related.length) alternative.push({ type: "multipart/related", subParts: [htmlPart, ...related.map(stripPart)] });
else alternative.push(htmlPart);
}
const regular = d.attachments.filter((a) => !a.inline && a.blobId && !a.error);
let bodyStructure: Record<string, unknown>;
const alt = html ? { type: "multipart/alternative", subParts: alternative } : alternative[0]!;
if (regular.length) {
bodyStructure = { type: "multipart/mixed", subParts: [alt, ...regular.map((a) => ({ blobId: a.blobId, type: a.type, name: a.name, disposition: "attachment" }))] };
} else bodyStructure = alt;
const obj: Record<string, unknown> = {
from: [from],
subject: d.subject,
sentAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
bodyStructure,
bodyValues,
"header:User-Agent:asText": "ihasmail/2.0",
};
// Header properties are omitted when empty, never sent as null: JMAP servers
// are entitled to reject null for a header field (Stalwart parses these as
// address lists and fails the whole create), and on a create there is no
// previous value that would need clearing.
const replyTo = d.replyTo.length ? d.replyTo : (ident.replyTo ?? []);
if (d.to.length) obj.to = d.to;
if (d.cc.length) obj.cc = d.cc;
if (d.bcc.length) obj.bcc = d.bcc;
if (replyTo.length) obj.replyTo = replyTo;
if (d.inReplyTo?.length) obj.inReplyTo = d.inReplyTo;
if (d.references?.length) obj.references = d.references;
if (d.priority === "high") {
obj["header:X-Priority:asText"] = "1 (Highest)";
obj["header:Importance:asText"] = "High";
} else if (d.priority === "low") {
obj["header:X-Priority:asText"] = "5 (Lowest)";
obj["header:Importance:asText"] = "Low";
}
if (d.requestReceipt) obj["header:Disposition-Notification-To:asAddresses"] = [from];
if (!opts.forSend) {
const draftsId = mail.roleId("drafts");
obj.mailboxIds = draftsId ? { [draftsId]: true } : { [mail.roleId("inbox")!]: true };
obj.keywords = { $draft: true, $seen: true };
} else {
const sentId = opts.mailboxId ?? mail.roleId("sent") ?? mail.roleId("inbox");
obj.mailboxIds = { [sentId!]: true };
obj.keywords = { $seen: true };
}
return obj;
}
function stripPart(p: EmailBodyPart): Record<string, unknown> {
return { blobId: p.blobId, type: p.type, name: p.name, disposition: p.disposition, cid: p.cid };
}
function wrapHtmlDocument(body: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head><body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;">${body}</body></html>`;
}
async function saveDraftInternal(d: Draft, get: () => ComposeState, set: (fn: (s: ComposeState) => Partial<ComposeState>) => void, opts: { silent: boolean; final?: boolean }): Promise<Id | null> {
const mail = useMail.getState();
const accountId = mail.accountId!;
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, saving: true } : x)) }));
try {
const email = await buildEmailObject(d, { forSend: false });
const args: Record<string, unknown> = { accountId, create: { draft: email } };
if (d.draftId) args.destroy = [d.draftId];
const res = await client.call<SetResponse<Email>>("Email/set", args);
const err = res.notCreated?.draft;
if (err) throw new Error(setErrorMessage(err));
const newId = res.created?.draft?.id ?? null;
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, draftId: newId, saving: false, dirty: false, savedAt: Date.now(), error: null } : x)) }));
void mail.loadMailboxes();
if (mail.list?.mailboxId && mail.list.mailboxId === mail.roleId("drafts")) void mail.refreshList();
return newId;
} catch (err) {
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, saving: false, error: (err as Error).message } : x)) }));
throw err;
}
}
/**
* The `EmailSubmission/set` create for a message, and the `Email` patch that
* files it once the server accepts it.
*
* A scheduled send differs in two places: the envelope carries a
* `HOLDUNTIL` parameter (RFC 4865 FUTURERELEASE, which is how JMAP asks for a
* delay -- `sendAt` itself is read-only and server-derived), and the message is
* filed under Scheduled rather than Sent, because it has not been sent yet.
*/
export function buildSubmission(opts: {
identityId: Id;
fromEmail: string;
emailRef: string;
rcpts: { email: string }[];
sentId: Id | null;
draftsId: Id | null;
scheduledId: Id | null;
sendAt: number | null;
}): { create: Record<string, unknown>; onSuccessUpdateEmail: Record<string, unknown> } {
const scheduled = opts.sendAt !== null;
const mailFrom: Record<string, unknown> = { email: opts.fromEmail };
if (scheduled) mailFrom.parameters = { HOLDUNTIL: holdUntil(new Date(opts.sendAt!)) };
const filedIn = scheduled ? opts.scheduledId : opts.sentId;
const onSuccess: Record<string, unknown> = { "keywords/$draft": null, "keywords/$seen": true };
if (filedIn) onSuccess[`mailboxIds/${filedIn}`] = true;
if (opts.draftsId && opts.draftsId !== filedIn) onSuccess[`mailboxIds/${opts.draftsId}`] = null;
if (scheduled && opts.sentId && opts.sentId !== filedIn) onSuccess[`mailboxIds/${opts.sentId}`] = null;
return {
create: { identityId: opts.identityId, emailId: opts.emailRef, envelope: { mailFrom, rcptTo: opts.rcpts } },
onSuccessUpdateEmail: onSuccess,
};
}
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
const mail = useMail.getState();
const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
if (!ident) throw new Error("No sending identity available");
if (d.attachments.some((a) => !a.blobId && !a.error)) throw new Error("Attachments are still uploading");
const scheduled = d.sendAt !== null && d.sendAt > Date.now();
const scheduledId = scheduled ? await ensureScheduledMailbox() : null;
const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId });
const sentId = mail.roleId("sent");
const draftsId = mail.roleId("drafts");
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
if (!rcpts.length) throw new Error("No recipients");
const sub = buildSubmission({
identityId: ident.id,
fromEmail: ident.email,
emailRef: "#m",
rcpts,
sentId,
draftsId,
scheduledId,
sendAt: scheduled ? d.sendAt : null,
});
const calls: Array<[string, Record<string, unknown>, string]> = [
["Email/set", { accountId, create: { m: email }, ...(d.draftId ? { destroy: [d.draftId] } : {}) }, "e"],
[
"EmailSubmission/set",
{ accountId, create: { s: sub.create }, onSuccessUpdateEmail: { "#s": sub.onSuccessUpdateEmail } },
"s",
],
];
if (d.relatedEmailId && d.relatedKeyword) {
calls.push(["Email/set", { accountId, update: { [d.relatedEmailId]: { [`keywords/${d.relatedKeyword}`]: true } } }, "k"]);
}
const res = await client.chain(calls, { allowErrors: true });
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
if (e.__error) throw new Error(setErrorMessage(e.__error));
if (e.notCreated?.m) throw new Error(setErrorMessage(e.notCreated.m));
const s = res.get("s")?.[0] as unknown as SetResponse & { __error?: { type: string; description?: string } };
if (s.__error) throw new Error(setErrorMessage(s.__error));
if (s.notCreated?.s) {
const err = s.notCreated.s;
// Clean up the created (unsent) email so it doesn't linger in Sent.
const created = e.created?.m?.id;
if (created) void client.call("Email/set", { accountId, destroy: [created] });
throw new Error(setErrorMessage(err));
}
if (d.relatedEmailId && d.relatedKeyword) {
useMail.setState((st) => {
const cur = st.emails[d.relatedEmailId!];
return cur ? { emails: { ...st.emails, [d.relatedEmailId!]: { ...cur, keywords: { ...cur.keywords, [d.relatedKeyword!]: true } } } } : {};
});
}
if (scheduled) {
// The server decides the release time, so take its word for it rather than
// ours -- and say so if the two disagree, which means the hold did not land
// the way we asked.
const created = (s.created?.s ?? {}) as { id?: Id; sendAt?: string; undoStatus?: string };
const settled = created.sendAt ? Date.parse(created.sendAt) : NaN;
if (!Number.isNaN(settled) && Math.abs(settled - d.sendAt!) > 60_000) {
toast.error(`The server scheduled this for ${formatScheduleTime(new Date(settled))}, not the time requested.`);
}
await useScheduled.getState().load();
}
void mail.loadMailboxes();
void mail.refreshList();
}
export { FULL_PROPS, BODY_PROPS };
/** Composer fields for a `mailto:` URL, including cc/bcc and a quoted body. */
export function draftFromMailto(url: string): Partial<Draft> {
const m = parseMailto(url);
const body = m.body ? `<div>${escapeHtml(m.body).replace(/\n/g, "<br>")}</div>` : "";
return {
to: m.to,
cc: m.cc,
bcc: m.bcc,
showCc: m.cc.length > 0,
showBcc: m.bcc.length > 0,
subject: m.subject,
...(body ? { html: body, text: m.body } : {}),
};
}