Scheduled send, which the README listed as needing server support that Stalwart has had all along. The delay cannot be asked for directly -- RFC 8621 makes `sendAt` read-only and server-derived -- so it goes on the envelope as an RFC 4865 `HOLDUNTIL` parameter, and the server reports back the time it settled on. Stalwart advertises this in the *account* capability, not the session-level one (which is empty): `maxDelayedSend` of thirty days and `FUTURERELEASE` among its `submissionExtensions`. The composer offers scheduling only when both are there, and never offers a time the server would refuse. A held message goes to a Scheduled folder rather than Sent, because `onSuccessUpdateEmail` would otherwise file it as sent the moment the submission is created, and it has not been sent. Nothing moves it out when the hold expires, so the folder is reconciled on the way in: released messages to Sent, cancelled ones back to Drafts. Cancelling uses a separate `Email/set` rather than `onSuccessUpdateEmail`, whose key Stalwart reads as an Email id and not, as the RFC says, a submission id. The mock grows the whole lifecycle, and learns to resolve creation references while it is there -- it had been quietly declining to create any submission at all, since sending names its message as `#m`. Because Stalwart's own `futureRelease` setting defaults to off and then drops the hold in silence, `npm run dev:mock:no-future-release` reproduces that. Verified end to end against the mock; not yet against the live server.
52 lines
2.0 KiB
TypeScript
52 lines
2.0 KiB
TypeScript
/**
|
|
* 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";
|
|
}
|