Merge remote-tracking branch 'origin/main' into feat/collect-recipients
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m46s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped

# Conflicts:
#	web/src/store/compose.ts
This commit is contained in:
jcoffey-dev committed 2026-10-05 12:04:39 -07:00
commit 29795f3988
12 files changed
+357 -7

No files matched your search

+116 -7
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { client, setErrorMessage } from "@/jmap/client";
import { ApiError, 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";
@@ -102,6 +102,12 @@ export interface Draft {
dlp?: DlpRefusalInfo | null;
/** The reason to send despite a DLP warning, for the next send only. */
dlpOverride?: string | null;
/**
* The Message-ID this draft goes out under, fixed at its first send and
* kept if the draft comes back after a failure. Sending it again asks the
* server first whether a message with this id already went out.
*/
sendMessageId?: string;
}
export interface DlpRefusalInfo {
@@ -738,8 +744,12 @@ export const useCompose = create<ComposeState>((set, get) => ({
},
async send(key) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const found = get().drafts.find((x) => x.key === key);
if (!found) return;
// A draft that already has a Message-ID has been sent before, and failed.
const retry = Boolean(found.sendMessageId);
const fromEmail = (useMail.getState().identities.find((i) => i.id === found.identityId) ?? useMail.getState().identities[0])?.email ?? "";
const d: Draft = { ...found, sendMessageId: found.sendMessageId ?? newMessageId(fromEmail) };
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();
@@ -754,12 +764,18 @@ export const useCompose = create<ComposeState>((set, get) => ({
return { pendingSends: rest };
});
try {
const sent = await sendInternal(d, get);
// Everyone written to who is not a contact yet, so they are suggested on every device.
const sent = await sendInternal(d, get, { retry });
// Everyone written to who is not a contact yet, so they are suggested on every
// device. Also when a resend found the first attempt had gone out: its reply
// was what got lost, so nothing was collected then.
if (settings().collectRecipients) {
const own = useMail.getState().identities.map((i) => i.email);
void useContacts.getState().collectRecipients([...d.to, ...d.cc, ...d.bcc], own).catch(() => undefined);
}
if (sent.alreadySent) {
toast.success(translate("This message had already been sent, so it wasn't sent again."));
return;
}
toast.success(
sent.held
? translate("Held for review: it's sent once a reviewer releases it")
@@ -774,6 +790,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
toast.error(err.info.kind === "warning" ? translate("Not sent yet: check the warning") : translate("Not sent: blocked by the server's rules"));
return;
}
if (err instanceof SendOutcomeUnknown) {
toast.error(err.message, {
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: err.message }], activeKey: d.key })) },
duration: 30000,
});
return;
}
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,
@@ -1087,9 +1110,54 @@ export function buildSubmission(opts: {
/** What the server said of a send: whether DLP held it for review (inbuxa). */
interface Sent {
held: boolean;
/** A resend found the first attempt had already gone out; nothing was sent again. */
alreadySent?: boolean;
}
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
/**
* A send whose outcome nobody can state: the request went out, no answer came
* back, and the server could not be asked afterwards either. Offering a plain
* "send again" here is how one message is delivered twice.
*/
export class SendOutcomeUnknown extends Error {}
/** A Message-ID for one send, on the sending identity's domain (RFC 5322 §3.6.4). */
export function newMessageId(fromEmail: string): string {
const domain = fromEmail.split("@")[1] || "localhost";
return `${crypto.randomUUID()}@${domain}`;
}
/**
* Whether a failed request may still have been carried out. A refusal (4xx)
* means the server did not run it; no answer, or a 5xx from the proxy
* that lost the upstream reply, means it may have.
*/
export function sendOutcomeUnknown(err: unknown): boolean {
if (err instanceof ApiError) return err.status >= 500 || err.status === 408;
return true;
}
/**
* What became of a send with this Message-ID.
*
* `sent`: a message carrying it exists and was submitted. `orphan`: it exists
* but no submission does, so it was created and never sent. `none`: nothing.
*
* Submissions are expunged after the server's hold period, so "no submission"
* means unsent only right after the attempt (`justNow`). Later, a message
* still carrying the id is taken as sent: every path that fails a send
* destroys the copy it created.
*/
export async function traceSend(accountId: Id, messageId: string, justNow: boolean): Promise<{ trace: "sent" | "orphan" | "none"; emailIds: Id[] }> {
const q = await client.call<{ ids: Id[] }>("Email/query", { accountId, filter: { header: ["Message-ID", messageId] } });
const emailIds = q.ids ?? [];
if (!emailIds.length) return { trace: "none", emailIds };
if (!justNow) return { trace: "sent", emailIds };
const subs = await client.call<{ ids: Id[] }>("EmailSubmission/query", { accountId, filter: { emailIds } });
return { trace: subs.ids?.length ? "sent" : "orphan", emailIds };
}
async function sendInternal(d: Draft, _get: () => ComposeState, opts: { retry?: boolean } = {}): Promise<Sent> {
const mail = useMail.getState();
const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
@@ -1098,6 +1166,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
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 messageId = d.sendMessageId ?? newMessageId(ident.email);
email.messageId = [messageId];
/*
* Sending again a draft that came back from a failed send: ask first. The
* failure may have been only the reply going missing, and a second
* submission would deliver the message twice.
*/
if (opts.retry) {
let found: Awaited<ReturnType<typeof traceSend>>;
try {
found = await traceSend(accountId, messageId, false);
} catch {
throw new SendOutcomeUnknown(translate("Couldn't check whether this message was already sent. Check Sent before sending it again."));
}
if (found.trace === "sent") {
if (d.draftId) void client.call("Email/set", { accountId, destroy: [d.draftId] });
void mail.loadMailboxes();
void mail.refreshList();
return { held: false, alreadySent: true };
}
}
const sentId = mail.roleId("sent");
const draftsId = mail.roleId("drafts");
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
@@ -1124,7 +1213,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
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 });
let res: Awaited<ReturnType<typeof client.chain>>;
try {
res = await client.chain(calls, { allowErrors: true });
} catch (err) {
if (!sendOutcomeUnknown(err)) throw err;
// No answer is not a no: find out what the server did with it.
let found: Awaited<ReturnType<typeof traceSend>>;
try {
found = await traceSend(accountId, messageId, true);
} catch {
throw new SendOutcomeUnknown(translate("Couldn't confirm whether this message was sent. Check Sent before sending it again."));
}
if (found.trace === "sent") {
void mail.loadMailboxes();
void mail.refreshList();
return { held: false };
}
// Created but never submitted: take it out of Sent, then report the failure.
if (found.trace === "orphan") void client.call("Email/set", { accountId, destroy: found.emailIds });
throw err;
}
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));