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:
2026-08-24 22:24:13 -07:00
21 changed files with 1227 additions and 38 deletions
+120
View File
@@ -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");
});
});
+119
View File
@@ -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);
}