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:
@@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect } from "react";
|
||||
import { Route, Switch, Redirect, useLocation } from "wouter";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { scheduleSupported, useScheduled } from "@/store/scheduled";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { useFiles } from "@/store/files";
|
||||
@@ -57,6 +58,9 @@ function AuthedApp() {
|
||||
void mail.loadMailboxes();
|
||||
void mail.loadIdentities();
|
||||
void mail.loadQuota();
|
||||
// So a held message shows its banner wherever it is opened from, not just
|
||||
// after a visit to the Scheduled folder.
|
||||
if (scheduleSupported()) void useScheduled.getState().load();
|
||||
void useContacts.getState().init();
|
||||
void useCalendar.getState().init();
|
||||
void useFiles.getState().init();
|
||||
|
||||
@@ -123,6 +123,16 @@ export class JmapClient {
|
||||
return Boolean(acc && cap in acc.accountCapabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* The capability object itself, for the capabilities that carry limits.
|
||||
* Stalwart puts the interesting half of `urn:ietf:params:jmap:submission`
|
||||
* here and leaves the session-level copy empty.
|
||||
*/
|
||||
accountCapability<T>(accountId: Id, cap: string): T | undefined {
|
||||
const acc = this.session?.accounts[accountId];
|
||||
return acc?.accountCapabilities[cap] as T | undefined;
|
||||
}
|
||||
|
||||
primaryAccount(cap: string): Id | null {
|
||||
return this.session?.primaryAccounts[cap] ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canScheduleSend,
|
||||
describeSpan,
|
||||
holdUntil,
|
||||
maxDelayMs,
|
||||
MIN_LEAD_MS,
|
||||
schedulePresets,
|
||||
scheduleError,
|
||||
} from "@/lib/schedule";
|
||||
|
||||
/** Stalwart's own numbers, from the account capability it advertises. */
|
||||
const STALWART = { maxDelayedSend: 86400 * 30, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [] } };
|
||||
const DAY = 86_400_000;
|
||||
|
||||
describe("capability detection", () => {
|
||||
it("accepts a server that advertises FUTURERELEASE and a non-zero window", () => {
|
||||
expect(canScheduleSend(STALWART)).toBe(true);
|
||||
expect(maxDelayMs(STALWART)).toBe(30 * DAY);
|
||||
});
|
||||
|
||||
it("refuses a server whose window is zero, which RFC 8621 defines as unsupported", () => {
|
||||
expect(canScheduleSend({ ...STALWART, maxDelayedSend: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a server that offers a window but not the extension", () => {
|
||||
expect(canScheduleSend({ maxDelayedSend: 86400, submissionExtensions: { DSN: [] } })).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses the empty capability object Stalwart puts at session level", () => {
|
||||
expect(canScheduleSend({})).toBe(false);
|
||||
expect(canScheduleSend(undefined)).toBe(false);
|
||||
expect(maxDelayMs(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("holdUntil", () => {
|
||||
it("is an RFC 3339 UTC date-time, which is what Stalwart parses since 0.16.17", () => {
|
||||
expect(holdUntil(new Date("2026-11-20T05:00:00Z"))).toBe("2026-11-20T05:00:00Z");
|
||||
});
|
||||
|
||||
it("drops milliseconds, so the sendAt that comes back agrees with what we asked", () => {
|
||||
expect(holdUntil(new Date("2026-11-20T05:00:00.789Z"))).toBe("2026-11-20T05:00:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedulePresets", () => {
|
||||
// A Monday morning: everything is still ahead.
|
||||
const monday9am = new Date(2026, 7, 24, 9, 0, 0, 0);
|
||||
|
||||
it("offers later today, tomorrow and next Monday from a Monday morning", () => {
|
||||
const ids = schedulePresets(monday9am, 30 * DAY).map((p) => p.id);
|
||||
expect(ids).toEqual(["later-today", "tomorrow-morning", "tomorrow-afternoon", "monday-morning"]);
|
||||
});
|
||||
|
||||
it("puts the times where the labels say", () => {
|
||||
const by = Object.fromEntries(schedulePresets(monday9am, 30 * DAY).map((p) => [p.id, p.at]));
|
||||
expect(by["later-today"]!.getHours()).toBe(17);
|
||||
expect(by["later-today"]!.getDate()).toBe(24);
|
||||
expect(by["tomorrow-morning"]!.getDate()).toBe(25);
|
||||
expect(by["tomorrow-morning"]!.getHours()).toBe(8);
|
||||
expect(by["tomorrow-afternoon"]!.getHours()).toBe(13);
|
||||
});
|
||||
|
||||
it("skips a Monday for the Monday a week out, not today", () => {
|
||||
const monday = schedulePresets(monday9am, 30 * DAY).find((p) => p.id === "monday-morning")!;
|
||||
expect(monday.at.getDate()).toBe(31);
|
||||
expect(monday.at.getDay()).toBe(1);
|
||||
});
|
||||
|
||||
it("finds next Monday from mid-week", () => {
|
||||
const wednesday = new Date(2026, 7, 26, 9, 0, 0, 0);
|
||||
const monday = schedulePresets(wednesday, 30 * DAY).find((p) => p.id === "monday-morning")!;
|
||||
expect(monday.at.getDate()).toBe(31);
|
||||
expect(monday.at.getDay()).toBe(1);
|
||||
});
|
||||
|
||||
it("drops later today once the evening has passed", () => {
|
||||
const ids = schedulePresets(new Date(2026, 7, 24, 18, 0, 0, 0), 30 * DAY).map((p) => p.id);
|
||||
expect(ids).not.toContain("later-today");
|
||||
expect(ids).toContain("tomorrow-morning");
|
||||
});
|
||||
|
||||
it("offers nothing beyond what the server will hold", () => {
|
||||
// A two-hour window reaches this evening but nothing after it.
|
||||
const ids = schedulePresets(new Date(2026, 7, 24, 16, 0, 0, 0), 2 * 3_600_000).map((p) => p.id);
|
||||
expect(ids).toEqual(["later-today"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduleError", () => {
|
||||
const now = new Date(2026, 7, 24, 9, 0, 0, 0);
|
||||
|
||||
it("accepts a time comfortably ahead", () => {
|
||||
expect(scheduleError(new Date(now.getTime() + DAY), now, 30 * DAY)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses the past and the almost-now", () => {
|
||||
expect(scheduleError(new Date(now.getTime() - 1000), now, 30 * DAY)).toMatch(/at least a minute/);
|
||||
expect(scheduleError(new Date(now.getTime() + MIN_LEAD_MS - 1), now, 30 * DAY)).toMatch(/at least a minute/);
|
||||
});
|
||||
|
||||
it("refuses what the server would reject, naming the limit", () => {
|
||||
const err = scheduleError(new Date(now.getTime() + 31 * DAY), now, 30 * DAY);
|
||||
expect(err).toMatch(/30 days/);
|
||||
});
|
||||
|
||||
it("refuses an unparseable date rather than sending one", () => {
|
||||
expect(scheduleError(new Date("nonsense"), now, 30 * DAY)).toMatch(/Pick a date/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeSpan", () => {
|
||||
it("reads in days when there are days, hours otherwise", () => {
|
||||
expect(describeSpan(30 * DAY)).toBe("30 days");
|
||||
expect(describeSpan(DAY)).toBe("1 day");
|
||||
expect(describeSpan(2 * 3_600_000)).toBe("2 hours");
|
||||
expect(describeSpan(3_600_000)).toBe("1 hour");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Scheduled send, as Stalwart actually implements it.
|
||||
*
|
||||
* JMAP does not let a client set `sendAt` directly -- RFC 8621 makes it a
|
||||
* server-derived property. The hold is requested through the SMTP
|
||||
* FUTURERELEASE extension (RFC 4865) instead, by putting a `HOLDUNTIL`
|
||||
* parameter on the envelope's `mailFrom`; the server parses it, holds the
|
||||
* message in its queue, and reports back the `sendAt` it settled on.
|
||||
*
|
||||
* Stalwart advertises the extension per-account rather than session-wide, so
|
||||
* the capability has to be read out of `accountCapabilities`, not the
|
||||
* top-level `capabilities` (where it is an empty object).
|
||||
*/
|
||||
import { addDays, startOfDay } from "./dates";
|
||||
import { formatFullDateTime } from "./datetime";
|
||||
|
||||
export const SUBMISSION_CAP = "urn:ietf:params:jmap:submission";
|
||||
|
||||
/** The account's `urn:ietf:params:jmap:submission` capability object. */
|
||||
export interface SubmissionCapability {
|
||||
maxDelayedSend?: number;
|
||||
submissionExtensions?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server will hold a message for us. Both halves matter: a server
|
||||
* may advertise the submission capability with `maxDelayedSend: 0`, which RFC
|
||||
* 8621 defines as "delayed sending is not supported".
|
||||
*/
|
||||
export function canScheduleSend(cap: SubmissionCapability | undefined | null): boolean {
|
||||
if (!cap) return false;
|
||||
const max = typeof cap.maxDelayedSend === "number" ? cap.maxDelayedSend : 0;
|
||||
const exts = cap.submissionExtensions ?? {};
|
||||
return max > 0 && Object.prototype.hasOwnProperty.call(exts, "FUTURERELEASE");
|
||||
}
|
||||
|
||||
/** How far ahead this server will hold a message, in milliseconds. */
|
||||
export function maxDelayMs(cap: SubmissionCapability | undefined | null): number {
|
||||
const max = cap && typeof cap.maxDelayedSend === "number" ? cap.maxDelayedSend : 0;
|
||||
return Math.max(0, max) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `HOLDUNTIL` parameter value. Stalwart parses this with its RFC 5321
|
||||
* parameter parser and wants an RFC 3339 date-time; it briefly wanted a Unix
|
||||
* timestamp instead, which was a bug fixed in 0.16.17.
|
||||
*
|
||||
* Seconds are truncated because the queue works in whole seconds anyway, and a
|
||||
* value carrying milliseconds only makes the round-tripped `sendAt` disagree
|
||||
* with what we asked for.
|
||||
*/
|
||||
export function holdUntil(at: Date): string {
|
||||
return new Date(Math.floor(at.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
export interface SchedulePreset {
|
||||
id: string;
|
||||
label: string;
|
||||
at: Date;
|
||||
}
|
||||
|
||||
/** The soonest we will offer to schedule: anything closer is just "Send". */
|
||||
export const MIN_LEAD_MS = 60_000;
|
||||
|
||||
function at(day: Date, hour: number): Date {
|
||||
const d = startOfDay(day);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gmail-style quick picks, minus any that have already passed or that fall
|
||||
* outside what the server will hold. "Later today" only appears while there is
|
||||
* still enough of the day left for it to mean anything.
|
||||
*/
|
||||
export function schedulePresets(now: Date, maxMs: number): SchedulePreset[] {
|
||||
const monday = (() => {
|
||||
// Next Monday; if today is Monday, the Monday a week out.
|
||||
const days = (8 - now.getDay()) % 7 || 7;
|
||||
return at(addDays(now, days), 8);
|
||||
})();
|
||||
const all: SchedulePreset[] = [
|
||||
{ id: "later-today", label: "Later today", at: at(now, 17) },
|
||||
{ id: "tomorrow-morning", label: "Tomorrow morning", at: at(addDays(now, 1), 8) },
|
||||
{ id: "tomorrow-afternoon", label: "Tomorrow afternoon", at: at(addDays(now, 1), 13) },
|
||||
{ id: "monday-morning", label: "Monday morning", at: monday },
|
||||
];
|
||||
const floor = now.getTime() + MIN_LEAD_MS;
|
||||
const ceiling = now.getTime() + maxMs;
|
||||
return all.filter((p) => p.at.getTime() >= floor && p.at.getTime() <= ceiling);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this instant will not do, or null if it will. The upper bound is the
|
||||
* server's own -- exceeding it makes Stalwart reject MAIL FROM outright, which
|
||||
* surfaces as a failed send rather than anything the user can act on.
|
||||
*/
|
||||
export function scheduleError(at: Date, now: Date, maxMs: number): string | null {
|
||||
const t = at.getTime();
|
||||
if (Number.isNaN(t)) return "Pick a date and time.";
|
||||
if (t < now.getTime() + MIN_LEAD_MS) return "Pick a time at least a minute from now.";
|
||||
if (maxMs > 0 && t > now.getTime() + maxMs) {
|
||||
return `This server will not hold a message longer than ${describeSpan(maxMs)}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "30 days", "7 days", "12 hours" -- for explaining the server's own limit. */
|
||||
export function describeSpan(ms: number): string {
|
||||
const days = Math.floor(ms / 86_400_000);
|
||||
if (days >= 1) return `${days} day${days === 1 ? "" : "s"}`;
|
||||
const hours = Math.max(1, Math.floor(ms / 3_600_000));
|
||||
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** How a scheduled time reads in menus, banners and toasts. */
|
||||
export function formatScheduleTime(at: Date): string {
|
||||
return formatFullDateTime(at);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
+72
-13
@@ -7,6 +7,8 @@ import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/l
|
||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
|
||||
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
|
||||
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
||||
import { settings } from "./settings";
|
||||
|
||||
export interface ComposeAttachment {
|
||||
@@ -59,6 +61,8 @@ export interface Draft {
|
||||
signatureHtml: string;
|
||||
replyMode: "reply" | "replyAll" | "forward" | null;
|
||||
mailboxIdOnSend?: Id | null;
|
||||
/** When set, hand the message to the server held until this instant. */
|
||||
sendAt: number | null;
|
||||
}
|
||||
|
||||
interface ComposeState {
|
||||
@@ -116,6 +120,7 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
|
||||
error: null,
|
||||
signatureHtml: "",
|
||||
replyMode: null,
|
||||
sendAt: null,
|
||||
...init,
|
||||
};
|
||||
}
|
||||
@@ -378,6 +383,8 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return;
|
||||
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();
|
||||
// Hide the composer immediately; actually send after the undo window.
|
||||
const t = autosaveTimers.get(key);
|
||||
if (t) window.clearTimeout(t);
|
||||
@@ -390,7 +397,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
});
|
||||
try {
|
||||
await sendInternal(d, get);
|
||||
toast.success("Message sent");
|
||||
toast.success(scheduling ? `Send scheduled for ${formatScheduleTime(new Date(d.sendAt!))}` : "Message sent");
|
||||
} catch (err) {
|
||||
toast.error(`Send failed: ${(err as Error).message}`, {
|
||||
action: { label: "Open draft", onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
|
||||
@@ -398,7 +405,10 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
});
|
||||
}
|
||||
};
|
||||
if (delay <= 0) {
|
||||
// A scheduled send is already delayed, and cancelling it is a server-side
|
||||
// operation from the Scheduled folder -- holding it locally first would
|
||||
// only add a second, different kind of undo.
|
||||
if (delay <= 0 || scheduling) {
|
||||
await doSend();
|
||||
return;
|
||||
}
|
||||
@@ -481,7 +491,7 @@ function scheduleAutosave(key: string, get: () => ComposeState) {
|
||||
}
|
||||
|
||||
/** Build the JMAP Email creation object from a draft. */
|
||||
export async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise<Record<string, unknown>> {
|
||||
export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailboxId?: Id | null }): Promise<Record<string, unknown>> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId!;
|
||||
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
|
||||
@@ -591,7 +601,7 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Pr
|
||||
obj.mailboxIds = draftsId ? { [draftsId]: true } : { [mail.roleId("inbox")!]: true };
|
||||
obj.keywords = { $draft: true, $seen: true };
|
||||
} else {
|
||||
const sentId = mail.roleId("sent") ?? mail.roleId("inbox");
|
||||
const sentId = opts.mailboxId ?? mail.roleId("sent") ?? mail.roleId("inbox");
|
||||
obj.mailboxIds = { [sentId!]: true };
|
||||
obj.keywords = { $seen: true };
|
||||
}
|
||||
@@ -628,29 +638,67 @@ async function saveDraftInternal(d: Draft, get: () => ComposeState, set: (fn: (s
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `EmailSubmission/set` create for a message, and the `Email` patch that
|
||||
* files it once the server accepts it.
|
||||
*
|
||||
* A scheduled send differs in two places: the envelope carries a
|
||||
* `HOLDUNTIL` parameter (RFC 4865 FUTURERELEASE, which is how JMAP asks for a
|
||||
* delay -- `sendAt` itself is read-only and server-derived), and the message is
|
||||
* filed under Scheduled rather than Sent, because it has not been sent yet.
|
||||
*/
|
||||
export function buildSubmission(opts: {
|
||||
identityId: Id;
|
||||
fromEmail: string;
|
||||
emailRef: string;
|
||||
rcpts: { email: string }[];
|
||||
sentId: Id | null;
|
||||
draftsId: Id | null;
|
||||
scheduledId: Id | null;
|
||||
sendAt: number | null;
|
||||
}): { create: Record<string, unknown>; onSuccessUpdateEmail: Record<string, unknown> } {
|
||||
const scheduled = opts.sendAt !== null;
|
||||
const mailFrom: Record<string, unknown> = { email: opts.fromEmail };
|
||||
if (scheduled) mailFrom.parameters = { HOLDUNTIL: holdUntil(new Date(opts.sendAt!)) };
|
||||
const filedIn = scheduled ? opts.scheduledId : opts.sentId;
|
||||
const onSuccess: Record<string, unknown> = { "keywords/$draft": null, "keywords/$seen": true };
|
||||
if (filedIn) onSuccess[`mailboxIds/${filedIn}`] = true;
|
||||
if (opts.draftsId && opts.draftsId !== filedIn) onSuccess[`mailboxIds/${opts.draftsId}`] = null;
|
||||
if (scheduled && opts.sentId && opts.sentId !== filedIn) onSuccess[`mailboxIds/${opts.sentId}`] = null;
|
||||
return {
|
||||
create: { identityId: opts.identityId, emailId: opts.emailRef, envelope: { mailFrom, rcptTo: opts.rcpts } },
|
||||
onSuccessUpdateEmail: onSuccess,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId!;
|
||||
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
|
||||
if (!ident) throw new Error("No sending identity available");
|
||||
if (d.attachments.some((a) => !a.blobId && !a.error)) throw new Error("Attachments are still uploading");
|
||||
const email = await buildEmailObject(d, { forSend: true });
|
||||
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 sentId = mail.roleId("sent");
|
||||
const draftsId = mail.roleId("drafts");
|
||||
const onSuccess: Record<string, unknown> = { "keywords/$draft": null, "keywords/$seen": true };
|
||||
if (sentId) onSuccess[`mailboxIds/${sentId}`] = true;
|
||||
if (draftsId) onSuccess[`mailboxIds/${draftsId}`] = null;
|
||||
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
|
||||
if (!rcpts.length) throw new Error("No recipients");
|
||||
const sub = buildSubmission({
|
||||
identityId: ident.id,
|
||||
fromEmail: ident.email,
|
||||
emailRef: "#m",
|
||||
rcpts,
|
||||
sentId,
|
||||
draftsId,
|
||||
scheduledId,
|
||||
sendAt: scheduled ? d.sendAt : null,
|
||||
});
|
||||
const calls: Array<[string, Record<string, unknown>, string]> = [
|
||||
["Email/set", { accountId, create: { m: email }, ...(d.draftId ? { destroy: [d.draftId] } : {}) }, "e"],
|
||||
[
|
||||
"EmailSubmission/set",
|
||||
{
|
||||
accountId,
|
||||
create: { s: { identityId: ident.id, emailId: "#m", envelope: { mailFrom: { email: ident.email }, rcptTo: rcpts } } },
|
||||
onSuccessUpdateEmail: { "#s": onSuccess },
|
||||
},
|
||||
{ accountId, create: { s: sub.create }, onSuccessUpdateEmail: { "#s": sub.onSuccessUpdateEmail } },
|
||||
"s",
|
||||
],
|
||||
];
|
||||
@@ -676,6 +724,17 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
return cur ? { emails: { ...st.emails, [d.relatedEmailId!]: { ...cur, keywords: { ...cur.keywords, [d.relatedKeyword!]: true } } } } : {};
|
||||
});
|
||||
}
|
||||
if (scheduled) {
|
||||
// The server decides the release time, so take its word for it rather than
|
||||
// ours -- and say so if the two disagree, which means the hold did not land
|
||||
// the way we asked.
|
||||
const created = (s.created?.s ?? {}) as { id?: Id; sendAt?: string; undoStatus?: string };
|
||||
const settled = created.sendAt ? Date.parse(created.sendAt) : NaN;
|
||||
if (!Number.isNaN(settled) && Math.abs(settled - d.sendAt!) > 60_000) {
|
||||
toast.error(`The server scheduled this for ${formatScheduleTime(new Date(settled))}, not the time requested.`);
|
||||
}
|
||||
await useScheduled.getState().load();
|
||||
}
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -488,6 +488,8 @@ img { max-width: 100%; }
|
||||
.message-body .body-host { display: block; }
|
||||
.remote-banner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; padding: 8px 12px; background: var(--warn-soft); color: var(--warn); border-radius: var(--radius-sm); font-size: .9em; }
|
||||
.remote-banner button { color: inherit; font-weight: 700; text-decoration: underline; }
|
||||
.scheduled-banner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; padding: 8px 12px; background: var(--accent-soft); color: var(--accent-soft-fg); border-radius: var(--radius-sm); font-size: .9em; }
|
||||
.scheduled-banner button { color: inherit; font-weight: 700; text-decoration: underline; }
|
||||
.quote-toggle { display: inline-flex; align-items: center; gap: 4px; margin: 8px 0; padding: 2px 10px; border-radius: 999px; background: var(--bg-sunken); color: var(--fg-muted); font-size: 12px; border: 1px solid var(--border); }
|
||||
.quote-toggle:hover { background: var(--bg-active); }
|
||||
.attachments { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 16px 16px; }
|
||||
|
||||
@@ -14,6 +14,9 @@ import { attachmentIcon } from "../mail/MessageView";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ScheduleDialog, ScheduleMenuItems } from "./SchedulePicker";
|
||||
import { scheduleSupported, scheduleWindowMs } from "@/store/scheduled";
|
||||
import { formatScheduleTime } from "@/lib/schedule";
|
||||
|
||||
export function Composer({ draft }: { draft: Draft }) {
|
||||
const update = useCompose((s) => s.update);
|
||||
@@ -36,6 +39,10 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
const sendMenu = useMenu();
|
||||
const templateMenu = useMenu();
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||
// Read once per render from the session; it cannot change while a composer is open.
|
||||
const canSchedule = scheduleSupported();
|
||||
const scheduleMax = canSchedule ? scheduleWindowMs() : 0;
|
||||
const d = draft;
|
||||
const key = d.key;
|
||||
// Where the caret starts, decided once when the composer opens: a blank
|
||||
@@ -90,6 +97,12 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
await send(key);
|
||||
};
|
||||
|
||||
const scheduleFor = (at: Date) => {
|
||||
sendMenu.close();
|
||||
setScheduleOpen(false);
|
||||
patch({ sendAt: at.getTime() });
|
||||
};
|
||||
|
||||
const toggleFormat = () => {
|
||||
if (d.format === "html") {
|
||||
patch({ format: "text", text: htmlToText(d.html) });
|
||||
@@ -173,6 +186,11 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} />
|
||||
{d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>}
|
||||
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
|
||||
{d.sendAt !== null && (
|
||||
<button type="button" className="tag" style={{ background: "var(--accent)" }} title="Scheduled — click to clear the schedule" onClick={() => patch({ sendAt: null })}>
|
||||
<Clock size={12} /> {formatScheduleTime(new Date(d.sendAt))} <X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{d.format === "html" ? (
|
||||
@@ -197,13 +215,19 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
)}
|
||||
<div className="composer-foot">
|
||||
<span className="send-group">
|
||||
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title="Send (Ctrl+Enter)"><Send size={16} /> Send</button>
|
||||
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title={d.sendAt !== null ? `Hand to the server, held until ${formatScheduleTime(new Date(d.sendAt))} (Ctrl+Enter)` : "Send (Ctrl+Enter)"}>
|
||||
{d.sendAt !== null ? <><Clock size={16} /> Schedule send</> : <><Send size={16} /> Send</>}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={sendMenu.open} aria-label="Send options"><ChevronDown size={16} /></button>
|
||||
</span>
|
||||
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={240}>
|
||||
<MenuItem icon={<Send size={16} />} label="Send" kbd="Ctrl+↵" onClick={() => void doSend()} />
|
||||
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={280}>
|
||||
<MenuItem icon={<Send size={16} />} label={d.sendAt !== null ? "Send now instead" : "Send"} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} />
|
||||
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
||||
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
|
||||
</Popover>
|
||||
{canSchedule && scheduleOpen && (
|
||||
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
|
||||
)}
|
||||
<span className="more-actions">
|
||||
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
|
||||
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { DateTimeField } from "@/ui/datefield";
|
||||
import { MenuItem, MenuSep, MenuTitle } from "@/ui/popover";
|
||||
import { describeSpan, formatScheduleTime, schedulePresets, scheduleError } from "@/lib/schedule";
|
||||
import { toInputDateTime, fromInputDateTime, roundToNext } from "@/lib/dates";
|
||||
|
||||
/**
|
||||
* The quick picks that hang off the composer's send menu. Anything the server
|
||||
* will not hold that long is simply not offered.
|
||||
*/
|
||||
export function ScheduleMenuItems({ maxMs, onPick, onCustom }: { maxMs: number; onPick: (at: Date) => void; onCustom: () => void }) {
|
||||
const presets = useMemo(() => schedulePresets(new Date(), maxMs), [maxMs]);
|
||||
return (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuTitle>Schedule send</MenuTitle>
|
||||
{presets.map((p) => (
|
||||
<MenuItem key={p.id} icon={<Clock size={16} />} label={p.label} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} />
|
||||
))}
|
||||
<MenuItem icon={<Clock size={16} />} label="Pick date and time…" onClick={onCustom} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The custom date/time dialog behind "Pick date and time…". */
|
||||
export function ScheduleDialog({ open, maxMs, initial, onClose, onPick }: {
|
||||
open: boolean;
|
||||
maxMs: number;
|
||||
initial: number | null;
|
||||
onClose: () => void;
|
||||
onPick: (at: Date) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(() => toInputDateTime(initial ? new Date(initial) : roundToNext(new Date(Date.now() + 3_600_000), 15)));
|
||||
const at = fromInputDateTime(value);
|
||||
const error = scheduleError(at, new Date(), maxMs);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Schedule send"
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={Boolean(error)} onClick={() => onPick(at)}>Schedule send</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="field">
|
||||
<label htmlFor="schedule-at">Send at</label>
|
||||
<DateTimeField id="schedule-at" value={value} onChange={setValue} aria-label="Date and time to send" />
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="hint" style={{ color: "var(--danger)" }}>{error}</p>
|
||||
) : (
|
||||
<p className="hint">
|
||||
The message waits on the server, so it goes out whether or not ihasmail is open.
|
||||
{maxMs > 0 && ` This server holds a message for up to ${describeSpan(maxMs)}.`}
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { LabelPicker } from "./LabelPicker";
|
||||
import type { Id } from "@/jmap/types";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
|
||||
|
||||
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
||||
const [, navigate] = useLocation();
|
||||
@@ -28,6 +29,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
||||
const [focusId, setFocusId] = useState<Id | null>(null);
|
||||
const [movePicker, setMovePicker] = useState<{ ids: Id[] } | null>(null);
|
||||
const [labelPicker, setLabelPicker] = useState<{ ids: Id[]; anchor: { x: number; y: number } } | null>(null);
|
||||
const reconcile = useScheduled((s) => s.reconcile);
|
||||
const scheduledId = useMail((s) => scheduledMailboxIdFrom(s.mailboxes));
|
||||
|
||||
const q = useMemo(() => (search ? (new URLSearchParams(searchStr).get("q") ?? "") : ""), [search, searchStr]);
|
||||
|
||||
@@ -47,14 +50,23 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
||||
}
|
||||
if (!mailboxId) return null;
|
||||
const mb = mailboxes[mailboxId];
|
||||
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent";
|
||||
// Scheduled joins Drafts and Sent as a folder of individual messages: they
|
||||
// are outgoing, and collapsing them into their threads hides them.
|
||||
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent" || mailboxId === scheduledId;
|
||||
return { key: "", filter: { inMailbox: mailboxId }, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId };
|
||||
}, [search, q, mailboxId, mailboxes, settings.conversationMode]);
|
||||
}, [search, q, mailboxId, mailboxes, settings.conversationMode, scheduledId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listQuery && mailboxesLoaded) void query(listQuery);
|
||||
}, [listQuery, query, mailboxesLoaded]);
|
||||
|
||||
// Nothing moves a message out of Scheduled when its hold expires, so settle
|
||||
// the folder up on the way in: sent messages to Sent, cancelled ones back to
|
||||
// Drafts, and refresh what is still waiting.
|
||||
useEffect(() => {
|
||||
if (mailboxesLoaded && mailboxId && mailboxId === scheduledId) void reconcile();
|
||||
}, [mailboxId, scheduledId, mailboxesLoaded, reconcile]);
|
||||
|
||||
const openThread = useCallback(
|
||||
(tid: Id | null) => {
|
||||
const base = search ? `/search` : `/mail/${mailboxId}`;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { AlertOctagon, Archive, ChevronDown, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react";
|
||||
import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { isScheduledMailbox } from "@/store/scheduled";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
@@ -128,11 +129,14 @@ export function MailboxTree() {
|
||||
|
||||
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void }) {
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const own = m.role === "drafts" ? m.totalEmails : m.unreadEmails;
|
||||
// Scheduled counts like Drafts: everything in it is already read, so the
|
||||
// useful number is how many messages are waiting, not how many are unseen.
|
||||
const scheduled = isScheduledMailbox(m);
|
||||
const own = m.role === "drafts" || scheduled ? m.totalEmails : m.unreadEmails;
|
||||
const count = own + hiddenUnread;
|
||||
// Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold).
|
||||
const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts";
|
||||
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : <Folder size={20} />;
|
||||
const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" && !scheduled ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts" && !scheduled;
|
||||
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? <Clock size={20} /> : <Folder size={20} />;
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Clock, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useMail } from "@/store/mail";
|
||||
@@ -20,6 +20,8 @@ import { InviteCard } from "./InviteCard";
|
||||
import { VCardCard } from "./VCardCard";
|
||||
import { AddressList, useAddressMenu } from "./AddressMenu";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useScheduled } from "@/store/scheduled";
|
||||
import { formatScheduleTime } from "@/lib/schedule";
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -47,6 +49,8 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
||||
const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email)));
|
||||
const remoteAllowed = allowRemote || settings.imagePolicy === "always" || senderTrusted || (settings.imagePolicy === "contacts" && inContacts);
|
||||
const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true);
|
||||
const scheduled = useScheduled((s) => s.pending[e.id]);
|
||||
const cancelScheduled = useScheduled((s) => s.cancel);
|
||||
|
||||
const htmlPart = e.htmlBody?.[0];
|
||||
const textPart = e.textBody?.[0];
|
||||
@@ -200,6 +204,24 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
||||
{receiptRequested && <><dt>Receipt</dt><dd>The sender requested a read receipt (not sent automatically).</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
{scheduled && (
|
||||
<div className="scheduled-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<Clock size={16} />
|
||||
<span className="grow">Waiting on the server — goes out {formatScheduleTime(new Date(scheduled.sendAt))}.</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancelScheduled(e.id);
|
||||
toast.success("Send cancelled — the message is back in Drafts");
|
||||
} catch (err) {
|
||||
toast.error(`Could not cancel: ${(err as Error).message}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel send
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
|
||||
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<ImageIcon size={16} />
|
||||
|
||||
Reference in New Issue
Block a user