Merge scheduled send
Both branches turn on where Stalwart advertises a capability, so they meet in the same two files. The mock keeps `urn:stalwart:jmap` out of the session-level capabilities and hands it out per-account, as a real server does, while the submission capability it grew for scheduled send lives per-account beside it; the client keeps both accessors, one asking whether a capability is advertised anywhere and one reading the object itself.
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();
|
||||
|
||||
@@ -137,6 +137,16 @@ export class JmapClient {
|
||||
return Object.values(this.session?.accounts ?? {}).some((a) => cap in (a.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