Merge pull request 'Never send a message twice after a lost reply' (#42) from fix/send-no-duplicates into main
This commit was merged in pull request #42.
This commit is contained in:
commit
38dcbd9e0c
12 files changed
+354
-6
No files matched your search
@@ -1355,6 +1355,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Trotzdem senden",
|
||||
"Send canceled — the message is back in Drafts": "Senden abgebrochen – die Nachricht liegt wieder in den Entwürfen",
|
||||
"Send failed: {error}": "Senden fehlgeschlagen: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Es konnte nicht geprüft werden, ob diese Nachricht schon gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Es konnte nicht bestätigt werden, ob diese Nachricht gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Diese Nachricht war bereits gesendet und wurde nicht noch einmal gesendet.",
|
||||
"Send invites": "Einladungen senden",
|
||||
"Send scheduled for {when}": "Senden geplant für {when}",
|
||||
"Send without a subject?": "Ohne Betreff senden?",
|
||||
|
||||
@@ -1328,6 +1328,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Enviar de todos modos",
|
||||
"Send canceled — the message is back in Drafts": "Envío cancelado: el mensaje ha vuelto a Borradores",
|
||||
"Send failed: {error}": "No se pudo enviar: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "No se pudo comprobar si este mensaje ya se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "No se pudo confirmar si este mensaje se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Este mensaje ya se había enviado, así que no se ha vuelto a enviar.",
|
||||
"Send invites": "Enviar invitaciones",
|
||||
"Send scheduled for {when}": "Envío programado para {when}",
|
||||
"Send without a subject?": "¿Enviar sin asunto?",
|
||||
|
||||
@@ -1333,6 +1333,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Envoyer quand même",
|
||||
"Send canceled — the message is back in Drafts": "Envoi annulé : le message est de retour dans les brouillons",
|
||||
"Send failed: {error}": "Échec de l’envoi : {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Impossible de vérifier si ce message a déjà été envoyé. Consultez Envoyés avant de le renvoyer.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Impossible de confirmer l’envoi de ce message. Consultez Envoyés avant de le renvoyer.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Ce message avait déjà été envoyé, il n’a donc pas été renvoyé.",
|
||||
"Send invites": "Envoyer les invitations",
|
||||
"Send scheduled for {when}": "Envoi programmé pour {when}",
|
||||
"Send without a subject?": "Envoyer sans objet ?",
|
||||
|
||||
@@ -1336,6 +1336,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "このまま送信",
|
||||
"Send canceled — the message is back in Drafts": "送信を取り消しました。メールは下書きに戻っています",
|
||||
"Send failed: {error}": "送信できませんでした: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "このメッセージが送信済みかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "このメッセージが送信されたかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||
"This message had already been sent, so it wasn't sent again.": "このメッセージはすでに送信されていたため、再送信しませんでした。",
|
||||
"Send invites": "招待を送信",
|
||||
"Send scheduled for {when}": "{when} に送信を予約しました",
|
||||
"Send without a subject?": "件名なしで送信しますか?",
|
||||
|
||||
@@ -1328,6 +1328,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Toch verzenden",
|
||||
"Send canceled — the message is back in Drafts": "Verzenden geannuleerd — het bericht staat weer bij Concepten",
|
||||
"Send failed: {error}": "Verzenden mislukt: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Kon niet controleren of dit bericht al is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Kon niet bevestigen of dit bericht is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Dit bericht was al verzonden en is daarom niet opnieuw verstuurd.",
|
||||
"Send invites": "Uitnodigingen verzenden",
|
||||
"Send scheduled for {when}": "Verzenden gepland voor {when}",
|
||||
"Send without a subject?": "Verzenden zonder een onderwerp?",
|
||||
|
||||
@@ -1331,6 +1331,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Enviar mesmo assim",
|
||||
"Send canceled — the message is back in Drafts": "Envio cancelado — a mensagem voltou para Rascunhos",
|
||||
"Send failed: {error}": "Falha ao enviar: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Não foi possível verificar se esta mensagem já foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Não foi possível confirmar se esta mensagem foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Esta mensagem já tinha sido enviada, então não foi enviada de novo.",
|
||||
"Send invites": "Enviar convites",
|
||||
"Send scheduled for {when}": "Envio agendado para {when}",
|
||||
"Send without a subject?": "Enviar sem assunto?",
|
||||
|
||||
@@ -1330,6 +1330,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Всё равно отправить",
|
||||
"Send canceled — the message is back in Drafts": "Отправка отменена — письмо вернулось в черновики",
|
||||
"Send failed: {error}": "Не удалось отправить: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не удалось проверить, было ли это письмо уже отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не удалось подтвердить, было ли это письмо отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Это письмо уже было отправлено, поэтому повторно оно не отправлялось.",
|
||||
"Send invites": "Отправить приглашения",
|
||||
"Send scheduled for {when}": "Отправка запланирована на {when}",
|
||||
"Send without a subject?": "Отправить без темы?",
|
||||
|
||||
@@ -1067,6 +1067,9 @@ export const catalog: Catalog = {
|
||||
"Send at": "Şu saatte gönder",
|
||||
"Send canceled — the message is back in Drafts": "Gönderim iptal edildi — ileti tekrar Taslaklar içinde",
|
||||
"Send failed: {error}": "Gönderme başarısız: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Bu iletinin daha önce gönderilip gönderilmediği denetlenemedi. Yeniden göndermeden önce Gönderilenler klasörüne bakın.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Bu iletinin gönderilip gönderilmediği doğrulanamadı. Yeniden göndermeden önce Gönderilenler klasörüne bakın.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Bu ileti zaten gönderilmişti, bu yüzden yeniden gönderilmedi.",
|
||||
"Send invitation emails to guests": "Konuklara e-posta ile davetiye gönder",
|
||||
"Send invites": "Davetleri gönder",
|
||||
"Send now instead": "Bunun yerine şimdi gönder",
|
||||
|
||||
@@ -1324,6 +1324,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Усе одно надіслати",
|
||||
"Send canceled — the message is back in Drafts": "Надсилання скасовано — лист повернувся до чернеток",
|
||||
"Send failed: {error}": "Не вдалося надіслати: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не вдалося перевірити, чи цей лист уже надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не вдалося підтвердити, чи цей лист надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Цей лист уже було надіслано, тому повторно його не надсилали.",
|
||||
"Send invites": "Надіслати запрошення",
|
||||
"Send scheduled for {when}": "Надсилання заплановано на {when}",
|
||||
"Send without a subject?": "Надіслати без теми?",
|
||||
|
||||
@@ -1335,6 +1335,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "仍然发送",
|
||||
"Send canceled — the message is back in Drafts": "已取消发送——邮件已回到草稿箱",
|
||||
"Send failed: {error}": "发送失败:{error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "无法检查这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "无法确认这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||
"This message had already been sent, so it wasn't sent again.": "这封邮件已经发送过,因此没有再次发送。",
|
||||
"Send invites": "发送邀请",
|
||||
"Send scheduled for {when}": "已定时于 {when} 发送",
|
||||
"Send without a subject?": "不填主题就发送吗?",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, CAP, client } from "@/jmap/client";
|
||||
import { sendOutcomeUnknown, useCompose } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import { useToasts } from "@/ui/toast";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* A send whose reply goes missing may still have gone out. "Send failed ->
|
||||
* Open draft -> Send" then delivered it twice: nothing said which message
|
||||
* the first attempt was, so nothing could check. Each send now carries its
|
||||
* own Message-ID, and an unanswered send asks the server what happened to it
|
||||
* before calling it failed.
|
||||
*/
|
||||
|
||||
type Mode = "ok" | "lost-reply" | "never-arrived" | "orphan" | "gateway" | "refused" | "offline-after";
|
||||
|
||||
interface Server {
|
||||
emails: { id: string; messageId: string }[];
|
||||
submissions: { id: string; emailId: string }[];
|
||||
destroyed: string[];
|
||||
creates: number;
|
||||
}
|
||||
|
||||
function server(mode: Mode): Server {
|
||||
const st: Server = { emails: [], submissions: [], destroyed: [], creates: 0 };
|
||||
let first = true;
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const sending = body.methodCalls.some(([n]) => n === "EmailSubmission/set");
|
||||
if (sending && first && mode !== "ok") {
|
||||
first = false;
|
||||
if (mode === "never-arrived") throw new TypeError("Failed to fetch");
|
||||
if (mode === "gateway") return { ok: false, status: 502, statusText: "Bad Gateway", json: async () => ({ error: "bad_gateway" }) } as Response;
|
||||
if (mode === "refused") return { ok: false, status: 400, statusText: "Bad Request", json: async () => ({ type: "urn:ietf:params:jmap:error:notRequest" }) } as Response;
|
||||
// The server does the work; only the answer is lost.
|
||||
const [, args] = body.methodCalls.find(([n, a]) => n === "Email/set" && (a as { create?: unknown }).create)!;
|
||||
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||
const id = `e${st.emails.length + 1}`;
|
||||
st.creates++;
|
||||
st.emails.push({ id, messageId: m.messageId[0]! });
|
||||
if (mode !== "orphan") st.submissions.push({ id: `s${id}`, emailId: id });
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
if (mode === "offline-after" && !first) throw new TypeError("Failed to fetch");
|
||||
if (mode === "offline-after" && sending) {
|
||||
first = false;
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Email/set" && (args as { create?: unknown }).create) {
|
||||
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||
const eid = `e${st.emails.length + 1}`;
|
||||
st.creates++;
|
||||
st.emails.push({ id: eid, messageId: m.messageId[0]! });
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: eid } } }, id];
|
||||
}
|
||||
if (name === "EmailSubmission/set") {
|
||||
const eid = st.emails[st.emails.length - 1]!.id;
|
||||
st.submissions.push({ id: `s${eid}`, emailId: eid });
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: `s${eid}` } } }, id];
|
||||
}
|
||||
if (name === "Email/set" && (args as { destroy?: string[] }).destroy) {
|
||||
st.destroyed.push(...((args as { destroy: string[] }).destroy));
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", destroyed: (args as { destroy: string[] }).destroy }, id];
|
||||
}
|
||||
if (name === "Email/query") {
|
||||
const [, value] = ((args.filter ?? {}) as { header?: [string, string] }).header ?? [];
|
||||
const ids = value ? st.emails.filter((e) => e.messageId === value && !st.destroyed.includes(e.id)).map((e) => e.id) : [];
|
||||
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||
}
|
||||
if (name === "EmailSubmission/query") {
|
||||
const want = ((args.filter ?? {}) as { emailIds?: string[] }).emailIds ?? [];
|
||||
const ids = st.submissions.filter((s) => want.includes(s.emailId)).map((s) => s.id);
|
||||
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
return st;
|
||||
}
|
||||
|
||||
const toastTexts = () => useToasts.getState().toasts.map((t) => t.message);
|
||||
|
||||
async function sendOne(init: Record<string, unknown> = {}) {
|
||||
const key = useCompose.getState().open({ to: [{ name: null, email: "[email protected]" }], subject: "Hi", ...init });
|
||||
await useCompose.getState().send(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as never,
|
||||
mailboxes: {
|
||||
mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" },
|
||||
mbDrafts: { id: "mbDrafts", role: "drafts", parentId: null, name: "Drafts" },
|
||||
} as never,
|
||||
});
|
||||
useToasts.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("which failures leave the outcome open", () => {
|
||||
it("treats a refusal as not sent, and no answer or a gateway error as unknown", () => {
|
||||
expect(sendOutcomeUnknown(new ApiError(400, "bad"))).toBe(false);
|
||||
expect(sendOutcomeUnknown(new ApiError(401, "unauthenticated"))).toBe(false);
|
||||
expect(sendOutcomeUnknown(new ApiError(502, "bad_gateway"))).toBe(true);
|
||||
expect(sendOutcomeUnknown(new ApiError(408, "timeout"))).toBe(true);
|
||||
expect(sendOutcomeUnknown(new TypeError("Failed to fetch"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sending once, whatever goes wrong on the way back", () => {
|
||||
it("gives every send a Message-ID on the sender's domain", async () => {
|
||||
const st = server("ok");
|
||||
await sendOne();
|
||||
expect(st.emails).toHaveLength(1);
|
||||
expect(st.emails[0]!.messageId).toMatch(/^[0-9a-f-]{36}@example\.org$/);
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
});
|
||||
|
||||
it("calls it sent when the reply was lost but the server sent it", async () => {
|
||||
const st = server("lost-reply");
|
||||
await sendOne();
|
||||
expect(st.submissions).toHaveLength(1);
|
||||
expect(st.destroyed).toEqual([]);
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
expect(useCompose.getState().drafts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes a message that was created but never submitted, and says it failed", async () => {
|
||||
const st = server("orphan");
|
||||
await sendOne();
|
||||
expect(st.destroyed).toEqual(["e1"]);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("says it failed when the request never reached the server", async () => {
|
||||
const st = server("never-arrived");
|
||||
await sendOne();
|
||||
expect(st.emails).toHaveLength(0);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("asks the server after a gateway error too, rather than assuming", async () => {
|
||||
const st = server("gateway");
|
||||
await sendOne();
|
||||
expect(st.creates).toBe(0);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not ask after a refusal: the server did not run it", async () => {
|
||||
server("refused");
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
await sendOne();
|
||||
const asked = fetchMock.mock.calls.some(([, init]) => (init as RequestInit).body?.toString().includes("Email/query"));
|
||||
expect(asked).toBe(false);
|
||||
});
|
||||
|
||||
it("says plainly when it cannot tell, instead of offering a resend that could duplicate", async () => {
|
||||
server("offline-after");
|
||||
await sendOne();
|
||||
const msgs = toastTexts();
|
||||
expect(msgs.some((t) => t.includes("Couldn't confirm whether this message was sent"))).toBe(true);
|
||||
expect(msgs.some((t) => t.startsWith("Send failed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sending a draft again after a failure", () => {
|
||||
it("keeps the Message-ID, and sends nothing when the first attempt went out", async () => {
|
||||
const st = server("ok");
|
||||
// The first attempt went out; the client never heard.
|
||||
st.emails.push({ id: "e1", messageId: "[email protected]" });
|
||||
st.submissions.push({ id: "se1", emailId: "e1" });
|
||||
await sendOne({ sendMessageId: "[email protected]" });
|
||||
expect(st.creates).toBe(0);
|
||||
expect(toastTexts().some((t) => t.includes("had already been sent"))).toBe(true);
|
||||
});
|
||||
|
||||
it("sends it, under the same Message-ID, when the first attempt did not go out", async () => {
|
||||
const st = server("ok");
|
||||
await sendOne({ sendMessageId: "[email protected]" });
|
||||
expect(st.creates).toBe(1);
|
||||
expect(st.emails[0]!.messageId).toBe("[email protected]");
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
});
|
||||
|
||||
it("reopens a failed draft with its Message-ID, so the next send can check", async () => {
|
||||
server("orphan");
|
||||
await sendOne();
|
||||
const toast = useToasts.getState().toasts.find((t) => t.message.startsWith("Send failed"))!;
|
||||
toast.action!.onClick();
|
||||
const reopened = useCompose.getState().drafts[0]!;
|
||||
expect(reopened.sendMessageId).toMatch(/@example\.org$/);
|
||||
});
|
||||
});
|
||||
+113
-6
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user