Never send a message twice after a lost reply
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 1m44s
ci / announce (pull_request) Skipped

When a send's request went out and no answer came back, the composer said
"Send failed" and offered the draft back. If the server had in fact
accepted it and only the reply was lost, sending the draft again
delivered the message twice. Nothing identified the first attempt, so
nothing could check.

Each send now carries its own Message-ID (on the sending identity's
domain, RFC 5322 §3.6.4), kept on the draft if it comes back. A failure
that may have happened after the server acted (no answer, a timeout, a
5xx from the proxy) is followed by asking the server what it did:

- a message with that Message-ID was submitted (EmailSubmission/query by
  emailIds, RFC 8621 §7.3): it was sent, and the composer says so;
- one exists with no submission: it was created and never sent; it is
  removed from Sent and the failure stands, so resending is safe;
- none exists: the failure stands;
- the server can't be asked either: the composer says it couldn't confirm
  and to check Sent, instead of a plain "Send failed".

A refusal (4xx) means the server did not run the request, so it is not
checked. Sending a draft again that came back from a failure asks first,
and sends nothing if a message with its Message-ID already exists;
submissions are expunged after the server's hold period, so later on any
surviving copy counts as sent (every failure path removes the copy it
made).

New strings: 3, in all ten catalogs (1699 -> 1702, no new fallbacks).
This commit is contained in:
jcoffey-dev committed 2026-10-05 11:43:55 -07:00
1 parent 3434a5ed39
commit 152311b035
12 files changed
+354 -6

No files matched your search

+113 -6
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,7 +764,11 @@ export const useCompose = create<ComposeState>((set, get) => ({
return { pendingSends: rest };
});
try {
const sent = await sendInternal(d, get);
const sent = await sendInternal(d, get, { retry });
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")
@@ -769,6 +783,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,
@@ -1082,9 +1103,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];
@@ -1093,6 +1159,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 }));
@@ -1119,7 +1206,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));