Replying sanitized the quoted body with allowRemote: true, so quoting fetched every remote image in the message whatever the reader had decided about it. A tracking pixel in the quote then reported the message read, and the address live, to whoever was counting -- the thing leaving the images blocked was meant to prevent. Edit as new and opening a draft that quotes a message did the same. The decision now lives in one place, remoteImagesAllowed(), asked with the same inputs the reader's answer used: the image policy, the trusted senders, whether the sender is a contact, and whether Show images was pressed on that message. The last of those was component state, so it moves to the mail store, where the composer can see it. Blocked images already keep their address in data-ihm-remote, so nothing is lost by not fetching: it goes back on the way out, and the sent quote is what its sender wrote. The recipient's client decides for itself, as it would with any other client's reply. Before pr408 this needed a rich-text default to reach; the format offer made it reachable from plain text, which is how it was found. No new strings.
1098 lines
51 KiB
TypeScript
1098 lines
51 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/text";
|
|
import { hasHtmlAlternative, sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
|
|
import { remoteImagesAllowed, restoreBlockedImages } from "@/lib/mail/remoteImages";
|
|
import { toast } from "@/ui/toast";
|
|
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
|
|
import { useSession } from "./session";
|
|
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
|
|
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
|
import { t as translate } from "@/lib/i18n";
|
|
import { BASE_PATH } from "@/lib/basePath";
|
|
import { settings } from "./settings";
|
|
import { useContacts } from "./contacts";
|
|
import { emlFilename } from "@/lib/text/emlName";
|
|
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
|
import { shareBody, type SharedContent } from "@/lib/shareTarget";
|
|
|
|
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;
|
|
/**
|
|
* The quoted message as it was prepared in each format, kept so that
|
|
* switching format re-attaches the original rather than a conversion of
|
|
* whatever the other format flattened it into. Empty on a draft that quotes
|
|
* nothing.
|
|
*/
|
|
quoteHtml: string;
|
|
quoteText: string;
|
|
/**
|
|
* The format the message being answered was written in, when it is not the
|
|
* one this draft opened in (#407). The composer offers the switch; answering
|
|
* it either way, or dismissing it, clears this.
|
|
*/
|
|
formatOffer: "html" | "text" | 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; run: () => Promise<void> }>;
|
|
/** Send everything still inside its undo window now. For signing out, while the session can still send. */
|
|
flushPendingSends(): Promise<void>;
|
|
open(init?: Partial<Draft>): string;
|
|
/** Open a draft holding what the operating system's share sheet sent us. */
|
|
openFromShare(share: SharedContent): string;
|
|
openDraftEmail(email: Email): Promise<string>;
|
|
/** Open a message again as a mail that has not been sent yet. */
|
|
composeAsNew(email: Email): Promise<string>;
|
|
reply(email: Email, mode: "reply" | "replyAll" | "forward", opts?: { all?: boolean }): Promise<string>;
|
|
/** Forward the message whole, as an attachment, rather than quoted into a new one. */
|
|
forwardAsAttachment(email: Email): 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: s.composeMaximized,
|
|
dirty: false,
|
|
savedAt: null,
|
|
saving: false,
|
|
sending: false,
|
|
error: null,
|
|
signatureHtml: "",
|
|
replyMode: null,
|
|
quoteHtml: "",
|
|
quoteText: "",
|
|
formatOffer: null,
|
|
sendAt: null,
|
|
...init,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether this message's remote images may be fetched into a composer.
|
|
*
|
|
* The same question the reader answered, asked with the same inputs: the
|
|
* policy, the trusted senders, whether the sender is a contact, and whether
|
|
* the reader pressed "Show images" on this message.
|
|
*/
|
|
function remoteImagesForMessage(email: Email): boolean {
|
|
const s = settings();
|
|
const from = email.from?.[0]?.email;
|
|
const contacts = useContacts.getState();
|
|
return remoteImagesAllowed({
|
|
from,
|
|
policy: s.imagePolicy,
|
|
trusted: s.trustedImageSenders,
|
|
inContacts: Boolean(from && contacts.loaded && contacts.lookupByEmail(from)),
|
|
shown: Boolean(useMail.getState().imagesShown[email.id]),
|
|
});
|
|
}
|
|
|
|
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;
|
|
},
|
|
|
|
/*
|
|
* A share from the operating system, as a message being written.
|
|
*
|
|
* The subject and body are filled in but nothing is addressed and nothing is
|
|
* sent: a share says what to send, never who to. What arrives is somebody
|
|
* part-way through a thought, and the composer is where the rest of it goes.
|
|
*
|
|
* Opened empty first and the body pushed in above afterwards, rather than
|
|
* passed to `open()`. `open()` only fits a signature when it is given no
|
|
* body at all, so handing it the shared text would quietly drop the
|
|
* signature from every message that started as a share.
|
|
*/
|
|
openFromShare(share) {
|
|
const body = shareBody(share);
|
|
const key = get().open({ subject: share.title.trim() });
|
|
if (body) {
|
|
const d = get().drafts.find((x) => x.key === key);
|
|
if (d) get().update(key, { html: `<div>${textToHtml(body)}</div>${d.html}`, text: `${body}\n${d.text}` });
|
|
}
|
|
if (share.files.length) get().addFiles(key, share.files);
|
|
return 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: remoteImagesForMessage(full), dropStyleBlocks: 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;
|
|
},
|
|
|
|
/*
|
|
* The same mail again, as a mail that has never been sent.
|
|
*
|
|
* Not a forward and not a reply: a mail that was rejected, or went to an
|
|
* address with a typo in it, is one you want to send *again* rather than pass
|
|
* on. So there is no Fwd: on the subject, no quote wrapper around the body,
|
|
* and the recipients it already had are the recipients it keeps.
|
|
*
|
|
* What makes it new is what is left out. `draftId` stays null, or sending
|
|
* would destroy the message this was made from; `inReplyTo`, `references`,
|
|
* `relatedEmailId` and `relatedKeyword` stay null, so nothing is threaded
|
|
* onto the old message and the old message is not marked answered or
|
|
* forwarded by sending this. The Message-ID and the date are the server's and
|
|
* `buildEmailObject`'s respectively, and neither is copied from anywhere, so
|
|
* both are new without anything here asking for it.
|
|
*/
|
|
async composeAsNew(email) {
|
|
const mail = useMail.getState();
|
|
const full = (await mail.getEmails([email.id], true))[0] ?? email;
|
|
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
|
|
// Sent by you, so send it as you again -- the same rule that reopens a
|
|
// draft. A mail somebody else sent has no identity of yours to match, and
|
|
// guessing from who it was addressed to would put a resend behind an alias
|
|
// that was only ever the receiving end; the account's own default is the
|
|
// honest answer there.
|
|
const ident =
|
|
identities.find((i) => full.from?.some((f) => sameAddress(f.email, i.email))) ??
|
|
mail.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 = mail.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({
|
|
identityId: ident?.id ?? null,
|
|
to: full.to ?? [],
|
|
cc: full.cc ?? [],
|
|
bcc: full.bcc ?? [],
|
|
// The message's own Reply-To if it carried one, which is the setting the
|
|
// report asks to keep; the identity's only when it did not.
|
|
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: remoteImagesForMessage(full), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
|
text: text || (html ? htmlToText(html) : ""),
|
|
format: html ? "html" : settings().composeFormat,
|
|
attachments,
|
|
// No signature is added, and `signatureHtml` is left empty on purpose.
|
|
// The body is the sent one, which already ends in whatever signature it
|
|
// was sent with; appending the identity's would give it two.
|
|
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((st) => ({ drafts: [...st.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);
|
|
/* `sameAddress` rather than a lowercased `includes`, because an identity
|
|
address can carry whitespace and a hand-typed one does. */
|
|
const isOwn = (a: EmailAddress) => ownEmails.some((e) => sameAddress(e, a.email));
|
|
const withoutOwn = (list: EmailAddress[]) => uniqueAddresses(list).filter((a) => !isOwn(a));
|
|
const s = settings();
|
|
|
|
/*
|
|
* Was this message mine?
|
|
*
|
|
* The folder answers it before the addresses do, and has to: the address
|
|
* test fails in exactly the cases where the mistake is least visible. A
|
|
* message sent from an alias or a shared mailbox that `Identity/get` does
|
|
* not list is not recognisably mine, and neither is anything at all if the
|
|
* identities have not loaded yet -- and the failure is silent, addressing
|
|
* the reply back to me with everyone I actually wrote to moved to Cc.
|
|
*
|
|
* A message in Sent is mine whatever address it went out as.
|
|
*/
|
|
const sentId = mail.roleId("sent");
|
|
const sentByMe = (Boolean(full.from?.length) && (full.from ?? []).every(isOwn))
|
|
|| Boolean(sentId && full.mailboxIds?.[sentId]);
|
|
|
|
let to: EmailAddress[] = [];
|
|
let cc: EmailAddress[] = [];
|
|
if (mode === "reply" || mode === "replyAll") {
|
|
if (sentByMe && (full.to?.length || full.cc?.length)) {
|
|
/*
|
|
* Replying to something I sent continues the conversation with the
|
|
* people I wrote to. Not with myself, and not with my own Reply-To
|
|
* either -- that address is where replies *to me* belong, and following
|
|
* it here would send my own reply to my own desk.
|
|
*/
|
|
to = withoutOwn(full.to ?? []);
|
|
cc = mode === "replyAll" ? withoutOwn(full.cc ?? []) : [];
|
|
// Addressed only to myself, or only in Cc: there is still somebody this
|
|
// is a reply to, and an empty To is not it.
|
|
if (!to.length) { to = cc.length ? cc : withoutOwn(full.cc ?? []); cc = []; }
|
|
if (!to.length) to = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]);
|
|
} else {
|
|
to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? []));
|
|
if (mode === "replyAll") {
|
|
cc = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]).filter((a) => !isOwn(a) && !to.some((t) => sameAddress(t.email, a.email)));
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ?? "") : "";
|
|
/*
|
|
* What the message being answered was really written in. `htmlBody` is
|
|
* derived, so its presence proves nothing -- hasHtmlAlternative() reads the
|
|
* part's own type. Getting this wrong would offer every plain-text message
|
|
* a switch to rich text it does not need.
|
|
*/
|
|
const origFormat = hasHtmlAlternative(htmlPart, origHtml) ? "html" : "text";
|
|
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 });
|
|
}
|
|
}
|
|
/*
|
|
* Quoting renders the message a second time, so the reader's decision
|
|
* about its remote images applies here too: a quote that fetched what
|
|
* they declined would report the message read to whoever was counting
|
|
* (#410). Blocked images keep their address and get it back on the way
|
|
* out, so the recipient's copy is the quote as its sender wrote it.
|
|
*/
|
|
const allowRemote = remoteImagesForMessage(full);
|
|
// 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, proxyRemote: false, dropStyleBlocks: true }).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,
|
|
quoteHtml,
|
|
quoteText: quoteTxt,
|
|
formatOffer: origFormat === s.composeFormat ? null : origFormat,
|
|
});
|
|
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
|
|
return d.key;
|
|
},
|
|
|
|
forwardAsAttachment(email) {
|
|
const accountId = useMail.getState().accountId;
|
|
const key = get().open({
|
|
subject: replySubject(email.subject, "Fwd"),
|
|
relatedEmailId: email.id,
|
|
relatedKeyword: "$forwarded",
|
|
replyMode: "forward",
|
|
});
|
|
// A message's own blobId *is* its RFC822 blob, and it already lives in this
|
|
// account -- so this goes through the same path as attach-from-Files and
|
|
// uploads nothing at all, however large the message.
|
|
//
|
|
// It inherits that path's size check as well, which is measured against
|
|
// `maxSizeUpload` even though nothing is being uploaded. That is worth
|
|
// knowing rather than working around here: the check belongs to
|
|
// `addFromFiles` and applies to every by-reference attachment, so if it is
|
|
// wrong it is wrong in one place and should be fixed there.
|
|
if (accountId) {
|
|
void get().addFromFiles(key, [{ accountId, name: emlFilename(email.subject), type: "message/rfc822", size: email.size, blobId: email.blobId }]);
|
|
}
|
|
return 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(translate("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(translate("Draft saved"));
|
|
} catch (err) {
|
|
toast.error(translate("Could not save draft: {error}", { error: (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 ? translate("Larger than {size} MB limit", { size: Math.round(max / 1048576) }) : 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 || translate("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) => {
|
|
/*
|
|
* `maxSizeUpload` is what the server will accept for a single *upload*
|
|
* (RFC 8620), so it only bears on a file that is about to be uploaded.
|
|
*
|
|
* A blob already in this account is attached by reference and nothing is
|
|
* sent, however large it is -- which is the whole point of attaching from
|
|
* Files, and of forwarding a message as an attachment. Applying the limit
|
|
* to those refused a 60 MB message the server was already holding, on the
|
|
* grounds that it could not have been uploaded, which it was not being.
|
|
*
|
|
* A file from somebody else's account is fetched and re-uploaded into
|
|
* this one, because a message can only carry blobs from the account
|
|
* sending it. That upload is real, and the limit is real for it.
|
|
*/
|
|
const byReference = n.accountId === accountId;
|
|
const tooLargeToUpload = !byReference && (n.size ?? 0) > max;
|
|
return {
|
|
id: uid("a"),
|
|
name: n.name,
|
|
type: n.type || "application/octet-stream",
|
|
size: n.size ?? 0,
|
|
blobId: byReference ? n.blobId : null,
|
|
progress: byReference ? 100 : 0,
|
|
error: tooLargeToUpload ? translate("Larger than {size} MB limit", { size: Math.round(max / 1048576) }) : 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 || translate("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(translate("Could not save draft: {error}", { error: (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 ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent"));
|
|
} catch (err) {
|
|
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), {
|
|
action: { label: translate("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 canceling 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(translate("Sending…"), { duration: delay * 1000, progress: true, action: { label: translate("Undo"), onClick: () => get().undoSend(key) } });
|
|
const timer = window.setTimeout(() => void doSend(), delay * 1000);
|
|
set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d, run: doSend } } }));
|
|
},
|
|
|
|
async flushPendingSends() {
|
|
const pending = Object.values(get().pendingSends);
|
|
for (const p of pending) {
|
|
window.clearTimeout(p.timer);
|
|
toast.dismiss(p.toastId);
|
|
}
|
|
await Promise.all(pending.map((p) => p.run()));
|
|
},
|
|
|
|
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;
|
|
// Placeholders are filled against the draft as it stands right now, which
|
|
// is why this happens on insert rather than on send: what the template is
|
|
// filled with is visible and editable afterwards, instead of changing
|
|
// under the message between writing it and sending it.
|
|
const ident = d.identityId ? useMail.getState().identities.find((i) => i.id === d.identityId) : undefined;
|
|
const ctx: PlaceholderContext = { to: d.to, from: ident ? { name: ident.name, email: ident.email } : null, subject: d.subject };
|
|
// The body is filled once as HTML and the plain-text side derived from the
|
|
// result, so the two cannot disagree about what a placeholder came to.
|
|
const filled = fillPlaceholders(html, ctx, { html: true });
|
|
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(filled)}</div>${d.html}`, text: `${htmlToText(filled)}\n${d.text}` };
|
|
if (subject && !d.subject) patch.subject = fillPlaceholders(subject, ctx, { html: false });
|
|
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),
|
|
);
|
|
}
|
|
|
|
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
/*
|
|
* How an inline image points at a blob while it is being edited, and how to
|
|
* find one again on the way out.
|
|
*
|
|
* `client.downloadUrl` builds these, so under a subpath they carry the mount
|
|
* prefix -- and the patterns have to as well. Neither would have failed
|
|
* loudly. A bare `/api/blob/` still appears *inside* `/mail/api/blob/...`, so
|
|
* the unanchored replacement would have matched only the tail and left `/mail`
|
|
* standing in front of a `cid:` reference; the anchored match would simply
|
|
* have missed, and the message would go out linking to the sender's own
|
|
* webmail where the picture should be.
|
|
*/
|
|
const BLOB_URL_PREFIX = `${BASE_PATH}/api/blob/`;
|
|
const BLOB_URL_RE = escapeRe(BLOB_URL_PREFIX);
|
|
|
|
/** 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(translate("No sending identity available"));
|
|
const from: EmailAddress = { name: ident.name || null, email: ident.email };
|
|
|
|
// Images blocked when the message was quoted keep their address; the copy
|
|
// that leaves carries it, and the recipient's client decides for itself.
|
|
let html = d.format === "html" ? restoreBlockedImages(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(`${BLOB_URL_RE}[^"' )]*${escapeRe(a.blobId)}[^"' )]*`, "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(BLOB_URL_PREFIX)) {
|
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
for (const img of Array.from(doc.querySelectorAll("img"))) {
|
|
const src = img.getAttribute("src") ?? "";
|
|
const m = new RegExp(`^${BLOB_URL_RE}([^/]+)/([^/]+)/([^?]+)(?:\\?([^#]*))?`).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(translate("No sending identity available"));
|
|
if (d.attachments.some((a) => !a.blobId && !a.error)) throw new Error(translate("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(translate("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(translate("The server scheduled this for {when}, not the time requested.", { when: formatScheduleTime(new Date(settled)) }));
|
|
}
|
|
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 } : {}),
|
|
};
|
|
}
|
|
|
|
/*
|
|
* Nothing written in one session is left for the next.
|
|
*
|
|
* The other stores let go of their data when the session ends; this one used
|
|
* to keep its open composers, so on a shared machine the next person to sign
|
|
* in -- without a reload, after an idle sign-out, say -- found the last one's
|
|
* draft open and could send it. A draft that was saved is still in Drafts on
|
|
* the server. A send still in its undo window was sent on the way out if the
|
|
* sign-out was a deliberate one (see `logout`); if the session had already
|
|
* ended there is nothing left to send it with, so its timer is stopped rather
|
|
* than let it fire under whoever signs in next.
|
|
*/
|
|
useSession.subscribe((s) => {
|
|
if (s.status !== "anonymous") return;
|
|
const { drafts, pendingSends } = useCompose.getState();
|
|
if (!drafts.length && !Object.keys(pendingSends).length) return;
|
|
for (const t of autosaveTimers.values()) window.clearTimeout(t);
|
|
autosaveTimers.clear();
|
|
for (const p of Object.values(pendingSends)) {
|
|
window.clearTimeout(p.timer);
|
|
toast.dismiss(p.toastId);
|
|
}
|
|
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
|
});
|