Hold a message in the server's queue until the time you asked for

Scheduled send, which the README listed as needing server support that
Stalwart has had all along. The delay cannot be asked for directly --
RFC 8621 makes `sendAt` read-only and server-derived -- so it goes on the
envelope as an RFC 4865 `HOLDUNTIL` parameter, and the server reports back
the time it settled on.

Stalwart advertises this in the *account* capability, not the session-level
one (which is empty): `maxDelayedSend` of thirty days and `FUTURERELEASE`
among its `submissionExtensions`. The composer offers scheduling only when
both are there, and never offers a time the server would refuse.

A held message goes to a Scheduled folder rather than Sent, because
`onSuccessUpdateEmail` would otherwise file it as sent the moment the
submission is created, and it has not been sent. Nothing moves it out when
the hold expires, so the folder is reconciled on the way in: released
messages to Sent, cancelled ones back to Drafts. Cancelling uses a separate
`Email/set` rather than `onSuccessUpdateEmail`, whose key Stalwart reads as
an Email id and not, as the RFC says, a submission id.

The mock grows the whole lifecycle, and learns to resolve creation
references while it is there -- it had been quietly declining to create any
submission at all, since sending names its message as `#m`. Because
Stalwart's own `futureRelease` setting defaults to off and then drops the
hold in silence, `npm run dev:mock:no-future-release` reproduces that.

Verified end to end against the mock; not yet against the live server.
This commit is contained in:
2026-08-24 22:07:29 -07:00
parent 03b5a6c388
commit e720623895
21 changed files with 1227 additions and 38 deletions
@@ -18,7 +18,7 @@ function draft(over: Partial<Draft> = {}): Draft {
requestReceipt: false, priority: "normal",
showCc: false, showBcc: false, showReplyTo: false,
minimized: false, maximized: false, dirty: false, savedAt: null,
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null,
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, sendAt: null,
...over,
};
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { buildSubmission } from "@/store/compose";
/**
* JMAP has no writable "send this later" property: `sendAt` is server-derived,
* and the delay is asked for with an RFC 4865 FUTURERELEASE parameter on the
* envelope. Getting that wrong sends the message immediately, which is not the
* kind of mistake a user can undo.
*/
const base = {
identityId: "i1",
fromEmail: "[email protected]",
emailRef: "#m",
rcpts: [{ email: "[email protected]" }],
sentId: "sent",
draftsId: "drafts",
scheduledId: "sched",
};
describe("buildSubmission", () => {
it("sends immediately when nothing is scheduled", () => {
const { create, onSuccessUpdateEmail } = buildSubmission({ ...base, sendAt: null });
const envelope = create.envelope as { mailFrom: Record<string, unknown> };
expect(envelope.mailFrom).toEqual({ email: "[email protected]" });
expect(envelope.mailFrom).not.toHaveProperty("parameters");
expect(onSuccessUpdateEmail["mailboxIds/sent"]).toBe(true);
expect(onSuccessUpdateEmail["mailboxIds/drafts"]).toBeNull();
});
it("asks for the hold with HOLDUNTIL, not by setting sendAt", () => {
const at = new Date("2026-11-20T05:00:00Z").getTime();
const { create } = buildSubmission({ ...base, sendAt: at });
const envelope = create.envelope as { mailFrom: { parameters?: Record<string, string> } };
expect(envelope.mailFrom.parameters).toEqual({ HOLDUNTIL: "2026-11-20T05:00:00Z" });
expect(create).not.toHaveProperty("sendAt");
expect(create).not.toHaveProperty("undoStatus");
});
it("files a held message under Scheduled, and keeps it out of Sent", () => {
const { onSuccessUpdateEmail } = buildSubmission({ ...base, sendAt: Date.now() + 86_400_000 });
expect(onSuccessUpdateEmail["mailboxIds/sched"]).toBe(true);
// Sent would be a lie for as long as the hold lasts.
expect(onSuccessUpdateEmail["mailboxIds/sent"]).toBeNull();
expect(onSuccessUpdateEmail["mailboxIds/drafts"]).toBeNull();
expect(onSuccessUpdateEmail["keywords/$draft"]).toBeNull();
});
it("still sends when the server has no Scheduled folder to file it in", () => {
const { create, onSuccessUpdateEmail } = buildSubmission({ ...base, scheduledId: null, sendAt: Date.now() + 86_400_000 });
const envelope = create.envelope as { mailFrom: { parameters?: Record<string, string> } };
expect(envelope.mailFrom.parameters).toHaveProperty("HOLDUNTIL");
expect(onSuccessUpdateEmail).not.toHaveProperty("mailboxIds/sched");
});
it("carries the identity, the message reference and every recipient", () => {
const { create } = buildSubmission({
...base,
rcpts: [{ email: "[email protected]" }, { email: "[email protected]" }],
sendAt: null,
});
expect(create.identityId).toBe("i1");
expect(create.emailId).toBe("#m");
expect((create.envelope as { rcptTo: unknown[] }).rcptTo).toEqual([{ email: "[email protected]" }, { email: "[email protected]" }]);
});
});
@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useMail } from "@/store/mail";
import { useScheduled } from "@/store/scheduled";
import { useToasts } from "@/ui/toast";
import type { JmapSession } from "@/jmap/types";
/**
* Nothing on the server moves a message out of Scheduled when its hold
* expires: Stalwart sends it and updates the submission, but the message stays
* in the folder ihasmail filed it in. Left alone, Scheduled slowly fills with
* mail that was sent days ago. Reconciling settles it on the way in.
*/
const SCHED = "mbSched";
const SENT = "mbSent";
const DRAFTS = "mbDrafts";
const FUTURE = "2099-01-01T00:00:00Z";
interface Sub {
id: string;
emailId: string;
sendAt: string;
undoStatus: "pending" | "final" | "canceled";
}
/** A server holding `inFolder` messages in Scheduled, with these submissions. */
function server(inFolder: string[], subs: Sub[]) {
const updates: Record<string, Record<string, unknown>>[] = [];
const submissionUpdates: Record<string, Record<string, unknown>>[] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
let queried: string[] = [];
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "Email/query") {
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids: inFolder, total: inFolder.length }, id];
}
if (name === "EmailSubmission/query") {
const f = (args.filter ?? {}) as { undoStatus?: string; emailIds?: string[] };
queried = subs
.filter((s) => (!f.undoStatus || s.undoStatus === f.undoStatus) && (!f.emailIds || f.emailIds.includes(s.emailId)))
.map((s) => s.id);
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids: queried, total: queried.length }, id];
}
if (name === "EmailSubmission/get") {
const ids = (args.ids as string[] | null) ?? queried;
return [name, { accountId: "a1", state: "1", list: subs.filter((s) => ids.includes(s.id)), notFound: [] }, id];
}
if (name === "EmailSubmission/set") {
submissionUpdates.push(args.update as Record<string, Record<string, unknown>>);
return [name, { accountId: "a1", oldState: "1", newState: "2", updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])) }, id];
}
if (name === "Email/set" && args.update) {
updates.push(args.update as Record<string, Record<string, unknown>>);
return [name, { accountId: "a1", oldState: "1", newState: "2", updated: {} }, 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;
});
vi.stubGlobal("fetch", fetchMock);
return { updates, submissionUpdates };
}
/** The mailbox a patch files a message into, and the one it takes it out of. */
function moved(patch: Record<string, unknown>) {
const into = Object.keys(patch).find((k) => k.startsWith("mailboxIds/") && patch[k] === true);
const outOf = Object.keys(patch).find((k) => k.startsWith("mailboxIds/") && patch[k] === null);
return { into: into?.slice("mailboxIds/".length), outOf: outOf?.slice("mailboxIds/".length) };
}
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {}, [CAP.submission]: {} },
accounts: { a1: { accountCapabilities: { [CAP.submission]: { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: [] } } } } },
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useMail.setState({
accountId: "a1",
mailboxes: {
[SCHED]: { id: SCHED, role: null, parentId: null, name: "Scheduled" },
[SENT]: { id: SENT, role: "sent", parentId: null, name: "Sent" },
[DRAFTS]: { id: DRAFTS, role: "drafts", parentId: null, name: "Drafts" },
} as never,
list: null,
emails: {},
});
useScheduled.setState({ pending: {}, loaded: false });
useToasts.setState({ toasts: [] });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("reconcile", () => {
it("leaves a message alone while its hold is still ahead", async () => {
const s = server(["e1"], [{ id: "s1", emailId: "e1", sendAt: FUTURE, undoStatus: "pending" }]);
await useScheduled.getState().reconcile();
expect(s.updates).toEqual([]);
expect(useScheduled.getState().pending.e1?.id).toBe("s1");
});
it("moves a released message to Sent, where it actually is", async () => {
const s = server(["e1"], [{ id: "s1", emailId: "e1", sendAt: "2026-01-01T00:00:00Z", undoStatus: "final" }]);
await useScheduled.getState().reconcile();
expect(s.updates).toHaveLength(1);
expect(moved(s.updates[0]!.e1!)).toEqual({ into: SENT, outOf: SCHED });
expect(useScheduled.getState().pending).toEqual({});
});
it("returns a message cancelled elsewhere to Drafts, as a draft again", async () => {
const s = server(["e1"], [{ id: "s1", emailId: "e1", sendAt: FUTURE, undoStatus: "canceled" }]);
await useScheduled.getState().reconcile();
expect(moved(s.updates[0]!.e1!)).toEqual({ into: DRAFTS, outOf: SCHED });
expect(s.updates[0]!.e1!["keywords/$draft"]).toBe(true);
});
it("treats a message with no submission at all as sent, not as a draft", async () => {
const s = server(["e1"], []);
await useScheduled.getState().reconcile();
expect(moved(s.updates[0]!.e1!).into).toBe(SENT);
});
it("settles a mixed folder in one call, keeping only what is still waiting", async () => {
const s = server(
["held", "gone", "dropped"],
[
{ id: "s1", emailId: "held", sendAt: FUTURE, undoStatus: "pending" },
{ id: "s2", emailId: "gone", sendAt: "2026-01-01T00:00:00Z", undoStatus: "final" },
{ id: "s3", emailId: "dropped", sendAt: FUTURE, undoStatus: "canceled" },
],
);
await useScheduled.getState().reconcile();
expect(s.updates).toHaveLength(1);
const patch = s.updates[0]!;
expect(Object.keys(patch).sort()).toEqual(["dropped", "gone"]);
expect(moved(patch.gone!).into).toBe(SENT);
expect(moved(patch.dropped!).into).toBe(DRAFTS);
expect(Object.keys(useScheduled.getState().pending)).toEqual(["held"]);
});
it("believes the newest submission when a message was rescheduled", async () => {
const s = server(
["e1"],
[
{ id: "old", emailId: "e1", sendAt: "2026-01-01T00:00:00Z", undoStatus: "canceled" },
{ id: "new", emailId: "e1", sendAt: FUTURE, undoStatus: "pending" },
],
);
await useScheduled.getState().reconcile();
expect(s.updates).toEqual([]);
expect(useScheduled.getState().pending.e1?.id).toBe("new");
});
it("keeps a message whose live hold was moved earlier than the one it replaced", async () => {
// Rescheduling to a sooner time leaves the cancelled submission holding the
// later sendAt. Going by timestamp alone would file a message back to
// Drafts while the queue still has it.
const s = server(
["e1"],
[
{ id: "old", emailId: "e1", sendAt: "2099-06-01T00:00:00Z", undoStatus: "canceled" },
{ id: "new", emailId: "e1", sendAt: FUTURE, undoStatus: "pending" },
],
);
await useScheduled.getState().reconcile();
expect(s.updates).toEqual([]);
expect(useScheduled.getState().pending.e1?.id).toBe("new");
});
it("does nothing at all when there is no Scheduled folder", async () => {
useMail.setState({ mailboxes: { [SENT]: { id: SENT, role: "sent", parentId: null, name: "Sent" } } as never });
const s = server(["e1"], []);
await useScheduled.getState().reconcile();
expect(s.updates).toEqual([]);
});
});
describe("cancel", () => {
it("cancels the submission and puts the message back in Drafts", async () => {
const s = server(["e1"], [{ id: "s1", emailId: "e1", sendAt: FUTURE, undoStatus: "pending" }]);
await useScheduled.getState().load();
expect(useScheduled.getState().pending.e1?.id).toBe("s1");
await useScheduled.getState().cancel("e1");
expect(s.submissionUpdates[0]).toEqual({ s1: { undoStatus: "canceled" } });
expect(moved(s.updates[0]!.e1!)).toEqual({ into: DRAFTS, outOf: SCHED });
expect(useScheduled.getState().pending).toEqual({});
});
it("refuses to cancel a message that is no longer waiting", async () => {
server([], []);
await expect(useScheduled.getState().cancel("e1")).rejects.toThrow(/no longer waiting/);
});
});