Files
ihasmail-inbuxa/web/src/store/scheduled.ts
T
jcoffey-dev e720623895 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.
2026-08-24 22:07:29 -07:00

239 lines
9.2 KiB
TypeScript

import { create } from "zustand";
import { client, ref, setErrorMessage } from "@/jmap/client";
import type { EmailSubmission, GetResponse, Id, Mailbox, QueryResponse, SetResponse } from "@/jmap/types";
import { toast } from "@/ui/toast";
import { useMail } from "./mail";
import { canScheduleSend, maxDelayMs, SUBMISSION_CAP, type SubmissionCapability } from "@/lib/schedule";
/**
* A held message lives in a folder of its own, the way Gmail's does, because
* the alternatives are both wrong: leaving it in Drafts invites the user to
* edit a message the queue has already frozen, and `onSuccessUpdateEmail`
* files it in Sent the instant the submission is created -- which for a
* scheduled send is a lie for however long the hold lasts.
*
* JMAP has no role for this (the IANA attribute registry has no `\Scheduled`),
* so it is an ordinary folder found by name.
*/
export const SCHEDULED_MAILBOX = "Scheduled";
/** What this account's server says about holding a message before sending it. */
export function submissionCapability(): SubmissionCapability | undefined {
const accountId = useMail.getState().accountId;
if (!accountId) return undefined;
return client.accountCapability<SubmissionCapability>(accountId, SUBMISSION_CAP);
}
/** Whether to offer scheduled send at all. */
export function scheduleSupported(): boolean {
return canScheduleSend(submissionCapability());
}
/** How far ahead this server will hold a message, in milliseconds. */
export function scheduleWindowMs(): number {
return maxDelayMs(submissionCapability());
}
/** Whether this folder is the one held messages wait in. */
export function isScheduledMailbox(m: Pick<Mailbox, "role" | "parentId" | "name">): boolean {
return !m.role && !m.parentId && m.name.toLowerCase() === SCHEDULED_MAILBOX.toLowerCase();
}
/** The Scheduled folder in a given set of mailboxes, if one exists yet. */
export function scheduledMailboxIdFrom(mailboxes: Record<Id, Mailbox>): Id | null {
return Object.values(mailboxes).find(isScheduledMailbox)?.id ?? null;
}
/** The Scheduled folder, if one exists yet. */
export function scheduledMailboxId(): Id | null {
return scheduledMailboxIdFrom(useMail.getState().mailboxes);
}
/** The Scheduled folder, creating it the first time something is scheduled. */
export async function ensureScheduledMailbox(): Promise<Id> {
const existing = scheduledMailboxId();
if (existing) return existing;
const accountId = useMail.getState().accountId!;
const res = await client.call<SetResponse<Mailbox>>("Mailbox/set", {
accountId,
create: { sched: { name: SCHEDULED_MAILBOX, parentId: null, isSubscribed: true } },
});
const err = res.notCreated?.sched;
// A racing tab (or another client) may have created it between the two calls.
if (err) {
await useMail.getState().loadMailboxes();
const again = scheduledMailboxId();
if (again) return again;
throw new Error(setErrorMessage(err));
}
const id = res.created!.sched!.id;
await useMail.getState().loadMailboxes();
return id;
}
export interface PendingSend {
id: Id;
emailId: Id;
/** Epoch milliseconds, as the server settled on it. */
sendAt: number;
undoStatus: EmailSubmission["undoStatus"];
}
interface ScheduledState {
/** Pending submissions, keyed by the message they will send. */
pending: Record<Id, PendingSend>;
loaded: boolean;
load(): Promise<void>;
cancel(emailId: Id): Promise<void>;
reconcile(): Promise<void>;
}
const SUB_PROPS = ["id", "emailId", "sendAt", "undoStatus"];
function toPending(s: EmailSubmission): PendingSend {
return { id: s.id, emailId: s.emailId, sendAt: Date.parse(s.sendAt), undoStatus: s.undoStatus };
}
/** Every submission still sitting in the server's queue. */
async function loadPending(accountId: Id): Promise<PendingSend[]> {
const res = await client.chain([
["EmailSubmission/query", { accountId, filter: { undoStatus: "pending" } }, "q"],
["EmailSubmission/get", { accountId, "#ids": ref("q", "EmailSubmission/query", "/ids"), properties: SUB_PROPS }, "g"],
]);
const got = res.get("g")?.[0] as unknown as GetResponse<EmailSubmission> | undefined;
return (got?.list ?? []).map(toPending);
}
/** The submissions belonging to a specific set of messages, whatever their status. */
async function loadFor(accountId: Id, emailIds: Id[]): Promise<PendingSend[]> {
if (!emailIds.length) return [];
const res = await client.chain([
["EmailSubmission/query", { accountId, filter: { emailIds } }, "q"],
["EmailSubmission/get", { accountId, "#ids": ref("q", "EmailSubmission/query", "/ids"), properties: SUB_PROPS }, "g"],
]);
const got = res.get("g")?.[0] as unknown as GetResponse<EmailSubmission> | undefined;
return (got?.list ?? []).map(toPending);
}
export const useScheduled = create<ScheduledState>((set, get) => ({
pending: {},
loaded: false,
async load() {
const accountId = useMail.getState().accountId;
if (!accountId) return;
try {
const list = await loadPending(accountId);
const pending: Record<Id, PendingSend> = {};
for (const s of list) pending[s.emailId] = s;
set({ pending, loaded: true });
} catch {
// A server without the submission capability simply has nothing to show.
set({ loaded: true });
}
},
async cancel(emailId) {
const accountId = useMail.getState().accountId!;
const sub = get().pending[emailId];
if (!sub) throw new Error("This message is no longer waiting to be sent");
const draftsId = useMail.getState().roleId("drafts");
const scheduledId = scheduledMailboxId();
// Not `onSuccessUpdateEmail`: RFC 8621 keys it by submission id, but
// Stalwart takes a plain key as an Email id and would patch the wrong
// object. Moving the message back is a separate call in the same request.
const res = await client.chain([
["EmailSubmission/set", { accountId, update: { [sub.id]: { undoStatus: "canceled" } } }, "s"],
[
"Email/set",
{
accountId,
update: {
[emailId]: {
"keywords/$draft": true,
...(draftsId ? { [`mailboxIds/${draftsId}`]: true } : {}),
...(scheduledId ? { [`mailboxIds/${scheduledId}`]: null } : {}),
},
},
},
"e",
],
], { allowErrors: true });
const setRes = res.get("s")?.[0] as unknown as SetResponse & { __error?: { type: string; description?: string } };
if (setRes.__error) throw new Error(setErrorMessage(setRes.__error));
const err = setRes.notUpdated?.[sub.id];
if (err) throw new Error(setErrorMessage(err));
set((s) => {
const { [emailId]: _drop, ...rest } = s.pending;
return { pending: rest };
});
const mail = useMail.getState();
void mail.loadMailboxes();
void mail.refreshList();
},
/**
* Nothing moves a message out of Scheduled when its hold expires -- the
* server sends it and updates the submission, but the message stays where we
* filed it. So on the way into the folder, settle up: what went out belongs
* in Sent, what was cancelled elsewhere belongs back in Drafts.
*/
async reconcile() {
const mail = useMail.getState();
const accountId = mail.accountId;
const scheduledId = scheduledMailboxId();
if (!accountId || !scheduledId) return;
try {
const q = await client.call<QueryResponse>("Email/query", {
accountId,
filter: { inMailbox: scheduledId },
limit: 200,
});
if (!q.ids.length) {
set({ pending: {} });
return;
}
const subs = await loadFor(accountId, q.ids);
// A message may carry several submissions if it was rescheduled. One
// still pending settles it whatever the timestamps say -- the queue holds
// a copy either way -- and otherwise the most recent wins.
const latest = new Map<Id, PendingSend>();
for (const s of subs) {
const prev = latest.get(s.emailId);
if (!prev) { latest.set(s.emailId, s); continue; }
if (prev.undoStatus === "pending") continue;
if (s.undoStatus === "pending" || s.sendAt >= prev.sendAt) latest.set(s.emailId, s);
}
const sentId = mail.roleId("sent");
const draftsId = mail.roleId("drafts");
const update: Record<Id, Record<string, unknown>> = {};
const pending: Record<Id, PendingSend> = {};
for (const emailId of q.ids) {
const s = latest.get(emailId);
if (s?.undoStatus === "pending") {
pending[emailId] = s;
continue;
}
// Cancelled goes back to Drafts; sent (or a submission the server no
// longer knows about) goes to Sent, which is where it actually is.
const toDrafts = s?.undoStatus === "canceled";
const dest = toDrafts ? draftsId : sentId;
if (!dest) continue;
update[emailId] = {
[`mailboxIds/${scheduledId}`]: null,
[`mailboxIds/${dest}`]: true,
...(toDrafts ? { "keywords/$draft": true } : {}),
};
}
set({ pending, loaded: true });
if (Object.keys(update).length) {
await client.call("Email/set", { accountId, update });
void mail.loadMailboxes();
void mail.refreshList();
}
} catch (err) {
toast.error(`Could not update the Scheduled folder: ${(err as Error).message}`);
}
},
}));