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:
+3
-2
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user