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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user