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
+3 -2
View File
@@ -9,9 +9,10 @@
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsx --test src/*.test.ts",
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
"mock": "tsx src/mock/index.ts",
"mock:legacy": "MOCK_STALWART=0.15 tsx src/mock/index.ts"
"mock:legacy": "MOCK_STALWART=0.15 tsx src/mock/index.ts",
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
+65
View File
@@ -0,0 +1,65 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
const NOW = Date.parse("2026-08-24T12:00:00Z");
const envelope = (parameters: Record<string, string> | null) => ({
mailFrom: { email: "[email protected]", ...(parameters ? { parameters } : {}) },
rcptTo: [{ email: "[email protected]" }],
});
describe("FUTURERELEASE parameters", () => {
it("reads HOLDUNTIL as an RFC 3339 date-time", () => {
const at = holdUntilOf(envelope({ HOLDUNTIL: "2026-11-20T05:00:00Z" }), NOW);
assert.equal(at, Date.parse("2026-11-20T05:00:00Z"));
});
it("reads HOLDFOR as a count of seconds from now", () => {
assert.equal(holdUntilOf(envelope({ HOLDFOR: "3600" }), NOW), NOW + 3_600_000);
});
it("matches the parameter name whatever its case, as an SMTP parser does", () => {
assert.equal(holdUntilOf(envelope({ holduntil: "2026-11-20T05:00:00Z" }), NOW), Date.parse("2026-11-20T05:00:00Z"));
});
it("means send now when neither parameter is present", () => {
assert.equal(holdUntilOf(envelope(null), NOW), null);
assert.equal(holdUntilOf(envelope({}), NOW), null);
assert.equal(holdUntilOf(undefined, NOW), null);
});
it("refuses both parameters at once, as Stalwart does with a 501", () => {
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDUNTIL: "2026-11-20T05:00:00Z", HOLDFOR: "600" }), NOW)));
});
it("refuses values that will not parse", () => {
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDUNTIL: "next tuesday" }), NOW)));
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDFOR: "soon" }), NOW)));
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDFOR: "0" }), NOW)));
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDFOR: "-60" }), NOW)));
});
it("accepts a Unix timestamp only as the date it is not", () => {
// 0.16.16 briefly wanted seconds-since-epoch here; 0.16.17 restored RFC
// 3339. A bare number must not be mistaken for a valid hold.
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDUNTIL: "1795000000" }), NOW)));
});
});
describe("undoStatus", () => {
const sub = (sendAt: string, undoStatus: string | null = null) => ({ sendAt, undoStatus });
it("is pending while the release time is still ahead", () => {
assert.equal(undoStatusOf(sub("2026-11-20T05:00:00Z"), NOW), "pending");
});
it("is final once the release time has passed", () => {
assert.equal(undoStatusOf(sub("2026-08-24T11:59:59Z"), NOW), "final");
assert.equal(undoStatusOf(sub("2026-08-24T12:00:00Z"), NOW), "final");
});
it("stays canceled regardless of the clock", () => {
assert.equal(undoStatusOf(sub("2026-11-20T05:00:00Z", "canceled"), NOW), "canceled");
assert.equal(undoStatusOf(sub("2026-01-01T00:00:00Z", "canceled"), NOW), "canceled");
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* FUTURERELEASE (RFC 4865) as Stalwart applies it to a JMAP envelope.
*
* A client asks for a delayed send by putting `HOLDUNTIL` (a date-time) or
* `HOLDFOR` (seconds) in the `mailFrom` parameters; Stalwart hands those to its
* RFC 5321 parameter parser and derives `sendAt` from the result. `sendAt` is
* never something the client sets. Kept apart from the mock server itself so
* the rules can be tested without binding a port.
*/
export type Obj = Record<string, unknown>;
/** Neither parameter given. */
export const NO_HOLD = null;
/** The parameters are contradictory or unparseable; the create must fail. */
export const BAD_HOLD = NaN;
function lookup(params: Obj, name: string): string | undefined {
const key = Object.keys(params).find((k) => k.toUpperCase() === name);
return key === undefined ? undefined : String(params[key]);
}
/**
* The instant an envelope asks to be released: null for "send it now", NaN for
* parameters the server would refuse.
*/
export function holdUntilOf(envelope: Obj | undefined, now: number): number | null {
const params = ((envelope?.mailFrom as Obj | undefined)?.parameters ?? {}) as Obj;
const until = lookup(params, "HOLDUNTIL");
const forSecs = lookup(params, "HOLDFOR");
// "501 5.5.4 Only one of HOLDFOR or HOLDUNTIL may be specified."
if (until !== undefined && forSecs !== undefined) return BAD_HOLD;
if (until !== undefined) {
const t = Date.parse(until);
return Number.isNaN(t) ? BAD_HOLD : t;
}
if (forSecs !== undefined) {
const secs = Number(forSecs);
return Number.isFinite(secs) && secs > 0 ? now + secs * 1000 : BAD_HOLD;
}
return NO_HOLD;
}
/**
* Pending while the message is still in the queue, which is what Stalwart
* reports: `undoStatus` is read off the spool, not stored on the submission.
*/
export function undoStatusOf(sub: Obj, now: number): "pending" | "final" | "canceled" {
if (sub.undoStatus === "canceled") return "canceled";
return Date.parse(String(sub.sendAt)) > now ? "pending" : "final";
}
+133 -9
View File
@@ -6,6 +6,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
const PORT = Number(process.env.MOCK_PORT ?? 8788);
/**
@@ -17,6 +18,15 @@ const PORT = Number(process.env.MOCK_PORT ?? 8788);
* credentials live behind a REST endpoint instead.
*/
const LEGACY = process.env.MOCK_STALWART === "0.15";
/**
* Stalwart advertises FUTURERELEASE in the session but only honours it when
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
* off, in which case the hold is dropped without a word and the message goes
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
*/
const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
/** What the session advertises, matching Stalwart's own 30 days. */
const MAX_DELAYED_SEND = 86400 * 30;
const ACCOUNT = "a1";
const USER = process.env.MOCK_USER ?? "[email protected]";
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
@@ -185,17 +195,43 @@ function pick(o: Obj, props?: string[] | null): Obj {
else if (p.startsWith("header:")) out[p] = null;
return out;
}
function resolveRefs(args: Obj, responses: [string, Obj, string][]): Obj {
function resolveRefs(args: Obj, responses: [string, Obj, string][], creations: Record<string, string>): Obj {
const out: Obj = {};
for (const [k, v] of Object.entries(args)) {
if (k.startsWith("#")) {
const r = v as { resultOf: string; name: string; path: string };
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
} else out[k] = v;
} else out[k] = resolveCreationIds(v, creations, k);
}
return out;
}
/**
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
* go, pointing at something created earlier in the same request. Sending a
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
* without this the mock quietly declines to create any submission at all.
*
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
* the method that receives them resolves them itself.
*/
function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
if (key === "onSuccessUpdateEmail") return value;
if (typeof value === "string") {
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
}
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
if (value && typeof value === "object") {
const out: Obj = {};
for (const [k, v] of Object.entries(value as Obj)) {
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
out[nk] = resolveCreationIds(v, creations, k);
}
return out;
}
return value;
}
function jsonPointer(obj: unknown, path: string): unknown {
const parts = path.split("/").filter(Boolean);
let cur: unknown = obj;
@@ -312,6 +348,26 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
};
}
/* ---------- submissions ---------- */
/**
* Held messages, the way Stalwart models them: `sendAt` is derived from the
* envelope's FUTURERELEASE parameter rather than set by the client, and
* `undoStatus` reports whether the message is still in the queue.
*/
const submissions: Obj[] = [];
function submissionView(sub: Obj): Obj {
return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) };
}
function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
if (!f) return true;
if (f.undoStatus && undoStatusOf(sub, Date.now()) !== f.undoStatus) return false;
if (Array.isArray(f.emailIds) && !(f.emailIds as string[]).includes(sub.emailId as string)) return false;
if (Array.isArray(f.identityIds) && !(f.identityIds as string[]).includes(sub.identityId as string)) return false;
return true;
}
const handlers: Record<string, Handler> = {
// 0.16 exposes the account locale here, under a permission ordinary users
// actually have (unlike x:Account below, which needs sysAccountGet).
@@ -439,18 +495,81 @@ const handlers: Record<string, Handler> = {
}
return genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o }))(a);
},
"EmailSubmission/get": (a) => {
const ids = a.ids as string[] | null | undefined;
const found = ids ? ids.map((id) => submissions.find((x) => x.id === id)).filter(Boolean) as Obj[] : submissions;
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(submissionView(x), a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !submissions.some((x) => x.id === id)) : [] };
},
"EmailSubmission/query": (a) => {
const list = submissions.filter((s) => matchSubmissionFilter(s, a.filter as Obj | undefined));
list.sort((x, y) => String(x.sendAt).localeCompare(String(y.sendAt)));
const pos = Number(a.position ?? 0);
const limit = Number(a.limit ?? 50);
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((s) => s.id), total: list.length, limit };
},
"EmailSubmission/set": (a) => {
const created: Obj = {};
for (const [cid, sub] of Object.entries((a.create as Obj) ?? {})) {
const emailId = (sub as Obj).emailId as string;
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
const sub = raw as Obj;
const emailId = sub.emailId as string;
const e = emails.find((x) => x.id === emailId);
if (!e) continue;
created[cid] = { id: `s${randomUUID().slice(0, 6)}`, sendAt: new Date().toISOString(), undoStatus: "final" };
if (!e) {
notCreated[cid] = { type: "invalidProperties", properties: ["emailId"], description: "Blob for email not found." };
continue;
}
const hold = holdUntilOf(sub.envelope as Obj | undefined, Date.now());
if (Number.isNaN(hold)) {
notCreated[cid] = { type: "invalidProperties", properties: ["envelope"], description: "Failed to parse mailFrom parameters." };
continue;
}
// Stalwart rejects MAIL FROM outright past its own limit.
if (hold !== null && hold > Date.now() + MAX_DELAYED_SEND * 1000) {
notCreated[cid] = { type: "forbiddenMailFrom", description: `Server rejected MAIL-FROM: 501 5.5.4 Requested release time exceeds maximum of ${new Date(Date.now() + MAX_DELAYED_SEND * 1000).toISOString()}.` };
continue;
}
// With the MTA extension off, the hold is dropped in silence.
const sendAt = hold !== null && !NO_FUTURE_RELEASE ? hold : Date.now();
const rec: Obj = {
id: `s${randomUUID().slice(0, 6)}`,
identityId: sub.identityId ?? null,
emailId,
threadId: e.threadId ?? null,
envelope: sub.envelope ?? null,
sendAt: new Date(sendAt).toISOString(),
undoStatus: null,
deliveryStatus: null,
};
submissions.push(rec);
created[cid] = { id: rec.id, sendAt: rec.sendAt, undoStatus: undoStatusOf(rec, Date.now()) };
const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined;
if (patch) applyPatch(e, patch);
}
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
const patch = raw as Obj;
const sub = submissions.find((x) => x.id === id);
if (!sub) { notUpdated[id] = { type: "notFound" }; continue; }
if (patch.undoStatus !== "canceled") {
notUpdated[id] = { type: "invalidProperties", properties: ["undoStatus"], description: "Only cancellation is supported." };
continue;
}
const status = undoStatusOf(sub, Date.now());
if (status !== "pending") {
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already cancelled." : "The message has already been sent." };
continue;
}
sub.undoStatus = "canceled";
updated[id] = null;
}
recount();
return setResp({ created });
return setResp({
created,
updated,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
});
},
"VacationResponse/get": () => ({ accountId: ACCOUNT, state: "1", list: [vacation], notFound: [] }),
"VacationResponse/set": (a) => { const p = ((a.update as Obj) ?? {}).singleton as Obj | undefined; if (p) vacation = { ...vacation, ...p }; return setResp({ updated: { singleton: null } }); },
@@ -545,7 +664,7 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
const session = () => ({
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
username: USER,
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
@@ -619,15 +738,20 @@ export const server = createServer(async (req, res) => {
}
const responses: [string, Obj, string][] = [];
const touched = new Set<string>();
const creations: Record<string, string> = {};
for (const [name, rawArgs, id] of body.methodCalls) {
const h = handlers[name];
// The registry, and every x: method with it, arrived in 0.16.
if (!h || (LEGACY && name.startsWith("x:"))) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
try {
const args = resolveRefs(rawArgs, responses);
const args = resolveRefs(rawArgs, responses, creations);
enforceLimits(name, args);
const r = h(args);
responses.push([name, r as Obj, id]);
for (const [cid, obj] of Object.entries(((r as Obj).created as Obj) ?? {})) {
const newId = (obj as Obj)?.id;
if (typeof newId === "string") creations[cid] = newId;
}
if (name.endsWith("/set") || name.endsWith("/import")) touched.add(name.split("/")[0]!);
} catch (err) {
if (err instanceof MethodError) responses.push(["error", { type: err.type, description: err.message }, id]);