diff --git a/README.md b/README.md index 97471d2..6256d0c 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ seconds of downtime with nothing lost. - Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click - **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address - Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel -- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, autosaved drafts, reply/reply-all/forward with quoting and inline images preserved +- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, **scheduled send** (quick picks or an exact date and time; the message waits in the server's queue, so it goes out whether or not ihasmail is open), autosaved drafts, reply/reply-all/forward with quoting and inline images preserved - Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge - A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts @@ -121,6 +121,10 @@ npm run dev:mock # mock on :8788, server on :8080, Vite on :5173 # the same, with the mock impersonating Stalwart 0.15 instead of 0.16 npm run dev:mock:legacy +# the same, with the mock advertising FUTURERELEASE but dropping every hold — +# the shape of a real server whose `futureRelease` setting was never turned on +npm run dev:mock:no-future-release + npm run typecheck # tsc for both packages npm test # vitest (web) + node:test (server) npm run build # web/dist + server/dist @@ -192,13 +196,14 @@ Both backends have now met a real server of their own generation. - **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline. - **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete are also confirmed; the older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap`, lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24). - **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path is confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The mock enforces the same rules either way (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens. +- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. **Not yet exercised against the live server** — verified end to end against the mock only. - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). - Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented. - The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed working on the live 0.16.19 instance, where the account locale now resolves for an ordinary user. ## Roadmap / not yet -- Snooze and scheduled send (needs server-side support) +- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away) - Read-receipt (MDN) sending, S/MIME / OpenPGP - Translations (strings are English-only for now) diff --git a/package.json b/package.json index 76d4c86..983abb9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "lint": "npm run typecheck", "mock": "npm run mock -w server", "dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"", - "dev:mock:legacy": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:legacy -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"" + "dev:mock:legacy": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:legacy -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"", + "dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"" }, "devDependencies": { "concurrently": "^9.1.2", diff --git a/server/package.json b/server/package.json index 6e12842..949a4f8 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/mock/futurerelease.test.ts b/server/src/mock/futurerelease.test.ts new file mode 100644 index 0000000..113d5ec --- /dev/null +++ b/server/src/mock/futurerelease.test.ts @@ -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 | null) => ({ + mailFrom: { email: "john@example.org", ...(parameters ? { parameters } : {}) }, + rcptTo: [{ email: "ann@example.com" }], +}); + +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"); + }); +}); diff --git a/server/src/mock/futurerelease.ts b/server/src/mock/futurerelease.ts new file mode 100644 index 0000000..d7d537c --- /dev/null +++ b/server/src/mock/futurerelease.ts @@ -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; + +/** 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"; +} diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 5a0ba10..bafe1e4 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -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 ?? "demo@example.com"; /** 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): 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, 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 = { // 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 = { } 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 { 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": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) }, - 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": {} } } }, + 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": {} } } }, 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/`, @@ -614,15 +733,20 @@ export const server = createServer(async (req, res) => { } const responses: [string, Obj, string][] = []; const touched = new Set(); + const creations: Record = {}; 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]); diff --git a/web/src/App.tsx b/web/src/App.tsx index e72ad99..f289129 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect } from "react"; import { Route, Switch, Redirect, useLocation } from "wouter"; import { useSession } from "@/store/session"; import { useMail } from "@/store/mail"; +import { scheduleSupported, useScheduled } from "@/store/scheduled"; import { useContacts } from "@/store/contacts"; import { useCalendar } from "@/store/calendar"; import { useFiles } from "@/store/files"; @@ -57,6 +58,9 @@ function AuthedApp() { void mail.loadMailboxes(); void mail.loadIdentities(); void mail.loadQuota(); + // So a held message shows its banner wherever it is opened from, not just + // after a visit to the Scheduled folder. + if (scheduleSupported()) void useScheduled.getState().load(); void useContacts.getState().init(); void useCalendar.getState().init(); void useFiles.getState().init(); diff --git a/web/src/jmap/client.ts b/web/src/jmap/client.ts index f84a85a..40a8cc4 100644 --- a/web/src/jmap/client.ts +++ b/web/src/jmap/client.ts @@ -123,6 +123,16 @@ export class JmapClient { return Boolean(acc && cap in acc.accountCapabilities); } + /** + * The capability object itself, for the capabilities that carry limits. + * Stalwart puts the interesting half of `urn:ietf:params:jmap:submission` + * here and leaves the session-level copy empty. + */ + accountCapability(accountId: Id, cap: string): T | undefined { + const acc = this.session?.accounts[accountId]; + return acc?.accountCapabilities[cap] as T | undefined; + } + primaryAccount(cap: string): Id | null { return this.session?.primaryAccounts[cap] ?? null; } diff --git a/web/src/lib/__tests__/schedule.test.ts b/web/src/lib/__tests__/schedule.test.ts new file mode 100644 index 0000000..af2d27e --- /dev/null +++ b/web/src/lib/__tests__/schedule.test.ts @@ -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"); + }); +}); diff --git a/web/src/lib/schedule.ts b/web/src/lib/schedule.ts new file mode 100644 index 0000000..2bda4b2 --- /dev/null +++ b/web/src/lib/schedule.ts @@ -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; +} + +/** + * 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); +} diff --git a/web/src/store/__tests__/compose-email.test.ts b/web/src/store/__tests__/compose-email.test.ts index 1424bcd..44a46fa 100644 --- a/web/src/store/__tests__/compose-email.test.ts +++ b/web/src/store/__tests__/compose-email.test.ts @@ -18,7 +18,7 @@ function draft(over: Partial = {}): Draft { requestReceipt: false, priority: "normal", showCc: false, showBcc: false, showReplyTo: false, minimized: false, maximized: false, dirty: false, savedAt: null, - saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, + saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, sendAt: null, ...over, }; } diff --git a/web/src/store/__tests__/compose-schedule.test.ts b/web/src/store/__tests__/compose-schedule.test.ts new file mode 100644 index 0000000..95e317e --- /dev/null +++ b/web/src/store/__tests__/compose-schedule.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { buildSubmission } from "@/store/compose"; + +/** + * JMAP has no writable "send this later" property: `sendAt` is server-derived, + * and the delay is asked for with an RFC 4865 FUTURERELEASE parameter on the + * envelope. Getting that wrong sends the message immediately, which is not the + * kind of mistake a user can undo. + */ +const base = { + identityId: "i1", + fromEmail: "john@example.org", + emailRef: "#m", + rcpts: [{ email: "ann@example.com" }], + sentId: "sent", + draftsId: "drafts", + scheduledId: "sched", +}; + +describe("buildSubmission", () => { + it("sends immediately when nothing is scheduled", () => { + const { create, onSuccessUpdateEmail } = buildSubmission({ ...base, sendAt: null }); + const envelope = create.envelope as { mailFrom: Record }; + expect(envelope.mailFrom).toEqual({ email: "john@example.org" }); + expect(envelope.mailFrom).not.toHaveProperty("parameters"); + expect(onSuccessUpdateEmail["mailboxIds/sent"]).toBe(true); + expect(onSuccessUpdateEmail["mailboxIds/drafts"]).toBeNull(); + }); + + it("asks for the hold with HOLDUNTIL, not by setting sendAt", () => { + const at = new Date("2026-11-20T05:00:00Z").getTime(); + const { create } = buildSubmission({ ...base, sendAt: at }); + const envelope = create.envelope as { mailFrom: { parameters?: Record } }; + expect(envelope.mailFrom.parameters).toEqual({ HOLDUNTIL: "2026-11-20T05:00:00Z" }); + expect(create).not.toHaveProperty("sendAt"); + expect(create).not.toHaveProperty("undoStatus"); + }); + + it("files a held message under Scheduled, and keeps it out of Sent", () => { + const { onSuccessUpdateEmail } = buildSubmission({ ...base, sendAt: Date.now() + 86_400_000 }); + expect(onSuccessUpdateEmail["mailboxIds/sched"]).toBe(true); + // Sent would be a lie for as long as the hold lasts. + expect(onSuccessUpdateEmail["mailboxIds/sent"]).toBeNull(); + expect(onSuccessUpdateEmail["mailboxIds/drafts"]).toBeNull(); + expect(onSuccessUpdateEmail["keywords/$draft"]).toBeNull(); + }); + + it("still sends when the server has no Scheduled folder to file it in", () => { + const { create, onSuccessUpdateEmail } = buildSubmission({ ...base, scheduledId: null, sendAt: Date.now() + 86_400_000 }); + const envelope = create.envelope as { mailFrom: { parameters?: Record } }; + expect(envelope.mailFrom.parameters).toHaveProperty("HOLDUNTIL"); + expect(onSuccessUpdateEmail).not.toHaveProperty("mailboxIds/sched"); + }); + + it("carries the identity, the message reference and every recipient", () => { + const { create } = buildSubmission({ + ...base, + rcpts: [{ email: "ann@example.com" }, { email: "bo@example.com" }], + sendAt: null, + }); + expect(create.identityId).toBe("i1"); + expect(create.emailId).toBe("#m"); + expect((create.envelope as { rcptTo: unknown[] }).rcptTo).toEqual([{ email: "ann@example.com" }, { email: "bo@example.com" }]); + }); +}); diff --git a/web/src/store/__tests__/scheduled-reconcile.test.ts b/web/src/store/__tests__/scheduled-reconcile.test.ts new file mode 100644 index 0000000..157352e --- /dev/null +++ b/web/src/store/__tests__/scheduled-reconcile.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import { useMail } from "@/store/mail"; +import { useScheduled } from "@/store/scheduled"; +import { useToasts } from "@/ui/toast"; +import type { JmapSession } from "@/jmap/types"; + +/** + * Nothing on the server moves a message out of Scheduled when its hold + * expires: Stalwart sends it and updates the submission, but the message stays + * in the folder ihasmail filed it in. Left alone, Scheduled slowly fills with + * mail that was sent days ago. Reconciling settles it on the way in. + */ + +const SCHED = "mbSched"; +const SENT = "mbSent"; +const DRAFTS = "mbDrafts"; +const FUTURE = "2099-01-01T00:00:00Z"; + +interface Sub { + id: string; + emailId: string; + sendAt: string; + undoStatus: "pending" | "final" | "canceled"; +} + +/** A server holding `inFolder` messages in Scheduled, with these submissions. */ +function server(inFolder: string[], subs: Sub[]) { + const updates: Record>[] = []; + const submissionUpdates: Record>[] = []; + const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string) as { methodCalls: [string, Record, string][] }; + let queried: string[] = []; + const methodResponses = body.methodCalls.map(([name, args, id]) => { + if (name === "Email/query") { + return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids: inFolder, total: inFolder.length }, id]; + } + if (name === "EmailSubmission/query") { + const f = (args.filter ?? {}) as { undoStatus?: string; emailIds?: string[] }; + queried = subs + .filter((s) => (!f.undoStatus || s.undoStatus === f.undoStatus) && (!f.emailIds || f.emailIds.includes(s.emailId))) + .map((s) => s.id); + return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids: queried, total: queried.length }, id]; + } + if (name === "EmailSubmission/get") { + const ids = (args.ids as string[] | null) ?? queried; + return [name, { accountId: "a1", state: "1", list: subs.filter((s) => ids.includes(s.id)), notFound: [] }, id]; + } + if (name === "EmailSubmission/set") { + submissionUpdates.push(args.update as Record>); + return [name, { accountId: "a1", oldState: "1", newState: "2", updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])) }, id]; + } + if (name === "Email/set" && args.update) { + updates.push(args.update as Record>); + return [name, { accountId: "a1", oldState: "1", newState: "2", updated: {} }, id]; + } + return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id]; + }); + return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + return { updates, submissionUpdates }; +} + +/** The mailbox a patch files a message into, and the one it takes it out of. */ +function moved(patch: Record) { + const into = Object.keys(patch).find((k) => k.startsWith("mailboxIds/") && patch[k] === true); + const outOf = Object.keys(patch).find((k) => k.startsWith("mailboxIds/") && patch[k] === null); + return { into: into?.slice("mailboxIds/".length), outOf: outOf?.slice("mailboxIds/".length) }; +} + +beforeEach(() => { + client.session = { + capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {}, [CAP.submission]: {} }, + accounts: { a1: { accountCapabilities: { [CAP.submission]: { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: [] } } } } }, + primaryAccounts: {}, + state: "s1", + } as unknown as JmapSession; + useMail.setState({ + accountId: "a1", + mailboxes: { + [SCHED]: { id: SCHED, role: null, parentId: null, name: "Scheduled" }, + [SENT]: { id: SENT, role: "sent", parentId: null, name: "Sent" }, + [DRAFTS]: { id: DRAFTS, role: "drafts", parentId: null, name: "Drafts" }, + } as never, + list: null, + emails: {}, + }); + useScheduled.setState({ pending: {}, loaded: false }); + useToasts.setState({ toasts: [] }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("reconcile", () => { + it("leaves a message alone while its hold is still ahead", async () => { + const s = server(["e1"], [{ id: "s1", emailId: "e1", sendAt: FUTURE, undoStatus: "pending" }]); + await useScheduled.getState().reconcile(); + expect(s.updates).toEqual([]); + expect(useScheduled.getState().pending.e1?.id).toBe("s1"); + }); + + it("moves a released message to Sent, where it actually is", async () => { + const s = server(["e1"], [{ id: "s1", emailId: "e1", sendAt: "2026-01-01T00:00:00Z", undoStatus: "final" }]); + await useScheduled.getState().reconcile(); + expect(s.updates).toHaveLength(1); + expect(moved(s.updates[0]!.e1!)).toEqual({ into: SENT, outOf: SCHED }); + expect(useScheduled.getState().pending).toEqual({}); + }); + + it("returns a message cancelled elsewhere to Drafts, as a draft again", async () => { + const s = server(["e1"], [{ id: "s1", emailId: "e1", sendAt: FUTURE, undoStatus: "canceled" }]); + await useScheduled.getState().reconcile(); + expect(moved(s.updates[0]!.e1!)).toEqual({ into: DRAFTS, outOf: SCHED }); + expect(s.updates[0]!.e1!["keywords/$draft"]).toBe(true); + }); + + it("treats a message with no submission at all as sent, not as a draft", async () => { + const s = server(["e1"], []); + await useScheduled.getState().reconcile(); + expect(moved(s.updates[0]!.e1!).into).toBe(SENT); + }); + + it("settles a mixed folder in one call, keeping only what is still waiting", async () => { + const s = server( + ["held", "gone", "dropped"], + [ + { id: "s1", emailId: "held", sendAt: FUTURE, undoStatus: "pending" }, + { id: "s2", emailId: "gone", sendAt: "2026-01-01T00:00:00Z", undoStatus: "final" }, + { id: "s3", emailId: "dropped", sendAt: FUTURE, undoStatus: "canceled" }, + ], + ); + await useScheduled.getState().reconcile(); + expect(s.updates).toHaveLength(1); + const patch = s.updates[0]!; + expect(Object.keys(patch).sort()).toEqual(["dropped", "gone"]); + expect(moved(patch.gone!).into).toBe(SENT); + expect(moved(patch.dropped!).into).toBe(DRAFTS); + expect(Object.keys(useScheduled.getState().pending)).toEqual(["held"]); + }); + + it("believes the newest submission when a message was rescheduled", async () => { + const s = server( + ["e1"], + [ + { id: "old", emailId: "e1", sendAt: "2026-01-01T00:00:00Z", undoStatus: "canceled" }, + { id: "new", emailId: "e1", sendAt: FUTURE, undoStatus: "pending" }, + ], + ); + await useScheduled.getState().reconcile(); + expect(s.updates).toEqual([]); + expect(useScheduled.getState().pending.e1?.id).toBe("new"); + }); + + it("keeps a message whose live hold was moved earlier than the one it replaced", async () => { + // Rescheduling to a sooner time leaves the cancelled submission holding the + // later sendAt. Going by timestamp alone would file a message back to + // Drafts while the queue still has it. + const s = server( + ["e1"], + [ + { id: "old", emailId: "e1", sendAt: "2099-06-01T00:00:00Z", undoStatus: "canceled" }, + { id: "new", emailId: "e1", sendAt: FUTURE, undoStatus: "pending" }, + ], + ); + await useScheduled.getState().reconcile(); + expect(s.updates).toEqual([]); + expect(useScheduled.getState().pending.e1?.id).toBe("new"); + }); + + it("does nothing at all when there is no Scheduled folder", async () => { + useMail.setState({ mailboxes: { [SENT]: { id: SENT, role: "sent", parentId: null, name: "Sent" } } as never }); + const s = server(["e1"], []); + await useScheduled.getState().reconcile(); + expect(s.updates).toEqual([]); + }); +}); + +describe("cancel", () => { + it("cancels the submission and puts the message back in Drafts", async () => { + const s = server(["e1"], [{ id: "s1", emailId: "e1", sendAt: FUTURE, undoStatus: "pending" }]); + await useScheduled.getState().load(); + expect(useScheduled.getState().pending.e1?.id).toBe("s1"); + await useScheduled.getState().cancel("e1"); + expect(s.submissionUpdates[0]).toEqual({ s1: { undoStatus: "canceled" } }); + expect(moved(s.updates[0]!.e1!)).toEqual({ into: DRAFTS, outOf: SCHED }); + expect(useScheduled.getState().pending).toEqual({}); + }); + + it("refuses to cancel a message that is no longer waiting", async () => { + server([], []); + await expect(useScheduled.getState().cancel("e1")).rejects.toThrow(/no longer waiting/); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 1702573..a5cc58a 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -7,6 +7,8 @@ import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/l import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html"; import { toast } from "@/ui/toast"; import { useMail, FULL_PROPS, BODY_PROPS } from "./mail"; +import { ensureScheduledMailbox, useScheduled } from "./scheduled"; +import { formatScheduleTime, holdUntil } from "@/lib/schedule"; import { settings } from "./settings"; export interface ComposeAttachment { @@ -59,6 +61,8 @@ export interface Draft { signatureHtml: string; replyMode: "reply" | "replyAll" | "forward" | null; mailboxIdOnSend?: Id | null; + /** When set, hand the message to the server held until this instant. */ + sendAt: number | null; } interface ComposeState { @@ -116,6 +120,7 @@ function blankDraft(init: Partial = {}): Draft { error: null, signatureHtml: "", replyMode: null, + sendAt: null, ...init, }; } @@ -378,6 +383,8 @@ export const useCompose = create((set, get) => ({ const d = get().drafts.find((x) => x.key === key); if (!d) return; const delay = settings().undoSendSeconds; + // A schedule the user left sitting until it passed is just a send now. + const scheduling = d.sendAt !== null && d.sendAt > Date.now(); // Hide the composer immediately; actually send after the undo window. const t = autosaveTimers.get(key); if (t) window.clearTimeout(t); @@ -390,7 +397,7 @@ export const useCompose = create((set, get) => ({ }); try { await sendInternal(d, get); - toast.success("Message sent"); + toast.success(scheduling ? `Send scheduled for ${formatScheduleTime(new Date(d.sendAt!))}` : "Message sent"); } catch (err) { toast.error(`Send failed: ${(err as Error).message}`, { action: { label: "Open draft", onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) }, @@ -398,7 +405,10 @@ export const useCompose = create((set, get) => ({ }); } }; - if (delay <= 0) { + // A scheduled send is already delayed, and cancelling it is a server-side + // operation from the Scheduled folder -- holding it locally first would + // only add a second, different kind of undo. + if (delay <= 0 || scheduling) { await doSend(); return; } @@ -481,7 +491,7 @@ function scheduleAutosave(key: string, get: () => ComposeState) { } /** Build the JMAP Email creation object from a draft. */ -export async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise> { +export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailboxId?: Id | null }): Promise> { const mail = useMail.getState(); const accountId = mail.accountId!; const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; @@ -591,7 +601,7 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Pr obj.mailboxIds = draftsId ? { [draftsId]: true } : { [mail.roleId("inbox")!]: true }; obj.keywords = { $draft: true, $seen: true }; } else { - const sentId = mail.roleId("sent") ?? mail.roleId("inbox"); + const sentId = opts.mailboxId ?? mail.roleId("sent") ?? mail.roleId("inbox"); obj.mailboxIds = { [sentId!]: true }; obj.keywords = { $seen: true }; } @@ -628,29 +638,67 @@ async function saveDraftInternal(d: Draft, get: () => ComposeState, set: (fn: (s } } +/** + * The `EmailSubmission/set` create for a message, and the `Email` patch that + * files it once the server accepts it. + * + * A scheduled send differs in two places: the envelope carries a + * `HOLDUNTIL` parameter (RFC 4865 FUTURERELEASE, which is how JMAP asks for a + * delay -- `sendAt` itself is read-only and server-derived), and the message is + * filed under Scheduled rather than Sent, because it has not been sent yet. + */ +export function buildSubmission(opts: { + identityId: Id; + fromEmail: string; + emailRef: string; + rcpts: { email: string }[]; + sentId: Id | null; + draftsId: Id | null; + scheduledId: Id | null; + sendAt: number | null; +}): { create: Record; onSuccessUpdateEmail: Record } { + const scheduled = opts.sendAt !== null; + const mailFrom: Record = { email: opts.fromEmail }; + if (scheduled) mailFrom.parameters = { HOLDUNTIL: holdUntil(new Date(opts.sendAt!)) }; + const filedIn = scheduled ? opts.scheduledId : opts.sentId; + const onSuccess: Record = { "keywords/$draft": null, "keywords/$seen": true }; + if (filedIn) onSuccess[`mailboxIds/${filedIn}`] = true; + if (opts.draftsId && opts.draftsId !== filedIn) onSuccess[`mailboxIds/${opts.draftsId}`] = null; + if (scheduled && opts.sentId && opts.sentId !== filedIn) onSuccess[`mailboxIds/${opts.sentId}`] = null; + return { + create: { identityId: opts.identityId, emailId: opts.emailRef, envelope: { mailFrom, rcptTo: opts.rcpts } }, + onSuccessUpdateEmail: onSuccess, + }; +} + async function sendInternal(d: Draft, _get: () => ComposeState): Promise { const mail = useMail.getState(); const accountId = mail.accountId!; const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; if (!ident) throw new Error("No sending identity available"); if (d.attachments.some((a) => !a.blobId && !a.error)) throw new Error("Attachments are still uploading"); - const email = await buildEmailObject(d, { forSend: true }); + const scheduled = d.sendAt !== null && d.sendAt > Date.now(); + const scheduledId = scheduled ? await ensureScheduledMailbox() : null; + const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId }); const sentId = mail.roleId("sent"); const draftsId = mail.roleId("drafts"); - const onSuccess: Record = { "keywords/$draft": null, "keywords/$seen": true }; - if (sentId) onSuccess[`mailboxIds/${sentId}`] = true; - if (draftsId) onSuccess[`mailboxIds/${draftsId}`] = null; const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email })); if (!rcpts.length) throw new Error("No recipients"); + const sub = buildSubmission({ + identityId: ident.id, + fromEmail: ident.email, + emailRef: "#m", + rcpts, + sentId, + draftsId, + scheduledId, + sendAt: scheduled ? d.sendAt : null, + }); const calls: Array<[string, Record, string]> = [ ["Email/set", { accountId, create: { m: email }, ...(d.draftId ? { destroy: [d.draftId] } : {}) }, "e"], [ "EmailSubmission/set", - { - accountId, - create: { s: { identityId: ident.id, emailId: "#m", envelope: { mailFrom: { email: ident.email }, rcptTo: rcpts } } }, - onSuccessUpdateEmail: { "#s": onSuccess }, - }, + { accountId, create: { s: sub.create }, onSuccessUpdateEmail: { "#s": sub.onSuccessUpdateEmail } }, "s", ], ]; @@ -676,6 +724,17 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise { return cur ? { emails: { ...st.emails, [d.relatedEmailId!]: { ...cur, keywords: { ...cur.keywords, [d.relatedKeyword!]: true } } } } : {}; }); } + if (scheduled) { + // The server decides the release time, so take its word for it rather than + // ours -- and say so if the two disagree, which means the hold did not land + // the way we asked. + const created = (s.created?.s ?? {}) as { id?: Id; sendAt?: string; undoStatus?: string }; + const settled = created.sendAt ? Date.parse(created.sendAt) : NaN; + if (!Number.isNaN(settled) && Math.abs(settled - d.sendAt!) > 60_000) { + toast.error(`The server scheduled this for ${formatScheduleTime(new Date(settled))}, not the time requested.`); + } + await useScheduled.getState().load(); + } void mail.loadMailboxes(); void mail.refreshList(); } diff --git a/web/src/store/scheduled.ts b/web/src/store/scheduled.ts new file mode 100644 index 0000000..b402277 --- /dev/null +++ b/web/src/store/scheduled.ts @@ -0,0 +1,238 @@ +import { create } from "zustand"; +import { client, ref, setErrorMessage } from "@/jmap/client"; +import type { EmailSubmission, GetResponse, Id, Mailbox, QueryResponse, SetResponse } from "@/jmap/types"; +import { toast } from "@/ui/toast"; +import { useMail } from "./mail"; +import { canScheduleSend, maxDelayMs, SUBMISSION_CAP, type SubmissionCapability } from "@/lib/schedule"; + +/** + * A held message lives in a folder of its own, the way Gmail's does, because + * the alternatives are both wrong: leaving it in Drafts invites the user to + * edit a message the queue has already frozen, and `onSuccessUpdateEmail` + * files it in Sent the instant the submission is created -- which for a + * scheduled send is a lie for however long the hold lasts. + * + * JMAP has no role for this (the IANA attribute registry has no `\Scheduled`), + * so it is an ordinary folder found by name. + */ +export const SCHEDULED_MAILBOX = "Scheduled"; + +/** What this account's server says about holding a message before sending it. */ +export function submissionCapability(): SubmissionCapability | undefined { + const accountId = useMail.getState().accountId; + if (!accountId) return undefined; + return client.accountCapability(accountId, SUBMISSION_CAP); +} + +/** Whether to offer scheduled send at all. */ +export function scheduleSupported(): boolean { + return canScheduleSend(submissionCapability()); +} + +/** How far ahead this server will hold a message, in milliseconds. */ +export function scheduleWindowMs(): number { + return maxDelayMs(submissionCapability()); +} + +/** Whether this folder is the one held messages wait in. */ +export function isScheduledMailbox(m: Pick): boolean { + return !m.role && !m.parentId && m.name.toLowerCase() === SCHEDULED_MAILBOX.toLowerCase(); +} + +/** The Scheduled folder in a given set of mailboxes, if one exists yet. */ +export function scheduledMailboxIdFrom(mailboxes: Record): Id | null { + return Object.values(mailboxes).find(isScheduledMailbox)?.id ?? null; +} + +/** The Scheduled folder, if one exists yet. */ +export function scheduledMailboxId(): Id | null { + return scheduledMailboxIdFrom(useMail.getState().mailboxes); +} + +/** The Scheduled folder, creating it the first time something is scheduled. */ +export async function ensureScheduledMailbox(): Promise { + const existing = scheduledMailboxId(); + if (existing) return existing; + const accountId = useMail.getState().accountId!; + const res = await client.call>("Mailbox/set", { + accountId, + create: { sched: { name: SCHEDULED_MAILBOX, parentId: null, isSubscribed: true } }, + }); + const err = res.notCreated?.sched; + // A racing tab (or another client) may have created it between the two calls. + if (err) { + await useMail.getState().loadMailboxes(); + const again = scheduledMailboxId(); + if (again) return again; + throw new Error(setErrorMessage(err)); + } + const id = res.created!.sched!.id; + await useMail.getState().loadMailboxes(); + return id; +} + +export interface PendingSend { + id: Id; + emailId: Id; + /** Epoch milliseconds, as the server settled on it. */ + sendAt: number; + undoStatus: EmailSubmission["undoStatus"]; +} + +interface ScheduledState { + /** Pending submissions, keyed by the message they will send. */ + pending: Record; + loaded: boolean; + load(): Promise; + cancel(emailId: Id): Promise; + reconcile(): Promise; +} + +const SUB_PROPS = ["id", "emailId", "sendAt", "undoStatus"]; + +function toPending(s: EmailSubmission): PendingSend { + return { id: s.id, emailId: s.emailId, sendAt: Date.parse(s.sendAt), undoStatus: s.undoStatus }; +} + +/** Every submission still sitting in the server's queue. */ +async function loadPending(accountId: Id): Promise { + const res = await client.chain([ + ["EmailSubmission/query", { accountId, filter: { undoStatus: "pending" } }, "q"], + ["EmailSubmission/get", { accountId, "#ids": ref("q", "EmailSubmission/query", "/ids"), properties: SUB_PROPS }, "g"], + ]); + const got = res.get("g")?.[0] as unknown as GetResponse | undefined; + return (got?.list ?? []).map(toPending); +} + +/** The submissions belonging to a specific set of messages, whatever their status. */ +async function loadFor(accountId: Id, emailIds: Id[]): Promise { + if (!emailIds.length) return []; + const res = await client.chain([ + ["EmailSubmission/query", { accountId, filter: { emailIds } }, "q"], + ["EmailSubmission/get", { accountId, "#ids": ref("q", "EmailSubmission/query", "/ids"), properties: SUB_PROPS }, "g"], + ]); + const got = res.get("g")?.[0] as unknown as GetResponse | undefined; + return (got?.list ?? []).map(toPending); +} + +export const useScheduled = create((set, get) => ({ + pending: {}, + loaded: false, + + async load() { + const accountId = useMail.getState().accountId; + if (!accountId) return; + try { + const list = await loadPending(accountId); + const pending: Record = {}; + for (const s of list) pending[s.emailId] = s; + set({ pending, loaded: true }); + } catch { + // A server without the submission capability simply has nothing to show. + set({ loaded: true }); + } + }, + + async cancel(emailId) { + const accountId = useMail.getState().accountId!; + const sub = get().pending[emailId]; + if (!sub) throw new Error("This message is no longer waiting to be sent"); + const draftsId = useMail.getState().roleId("drafts"); + const scheduledId = scheduledMailboxId(); + // Not `onSuccessUpdateEmail`: RFC 8621 keys it by submission id, but + // Stalwart takes a plain key as an Email id and would patch the wrong + // object. Moving the message back is a separate call in the same request. + const res = await client.chain([ + ["EmailSubmission/set", { accountId, update: { [sub.id]: { undoStatus: "canceled" } } }, "s"], + [ + "Email/set", + { + accountId, + update: { + [emailId]: { + "keywords/$draft": true, + ...(draftsId ? { [`mailboxIds/${draftsId}`]: true } : {}), + ...(scheduledId ? { [`mailboxIds/${scheduledId}`]: null } : {}), + }, + }, + }, + "e", + ], + ], { allowErrors: true }); + const setRes = res.get("s")?.[0] as unknown as SetResponse & { __error?: { type: string; description?: string } }; + if (setRes.__error) throw new Error(setErrorMessage(setRes.__error)); + const err = setRes.notUpdated?.[sub.id]; + if (err) throw new Error(setErrorMessage(err)); + set((s) => { + const { [emailId]: _drop, ...rest } = s.pending; + return { pending: rest }; + }); + const mail = useMail.getState(); + void mail.loadMailboxes(); + void mail.refreshList(); + }, + + /** + * Nothing moves a message out of Scheduled when its hold expires -- the + * server sends it and updates the submission, but the message stays where we + * filed it. So on the way into the folder, settle up: what went out belongs + * in Sent, what was cancelled elsewhere belongs back in Drafts. + */ + async reconcile() { + const mail = useMail.getState(); + const accountId = mail.accountId; + const scheduledId = scheduledMailboxId(); + if (!accountId || !scheduledId) return; + try { + const q = await client.call("Email/query", { + accountId, + filter: { inMailbox: scheduledId }, + limit: 200, + }); + if (!q.ids.length) { + set({ pending: {} }); + return; + } + const subs = await loadFor(accountId, q.ids); + // A message may carry several submissions if it was rescheduled. One + // still pending settles it whatever the timestamps say -- the queue holds + // a copy either way -- and otherwise the most recent wins. + const latest = new Map(); + for (const s of subs) { + const prev = latest.get(s.emailId); + if (!prev) { latest.set(s.emailId, s); continue; } + if (prev.undoStatus === "pending") continue; + if (s.undoStatus === "pending" || s.sendAt >= prev.sendAt) latest.set(s.emailId, s); + } + const sentId = mail.roleId("sent"); + const draftsId = mail.roleId("drafts"); + const update: Record> = {}; + const pending: Record = {}; + for (const emailId of q.ids) { + const s = latest.get(emailId); + if (s?.undoStatus === "pending") { + pending[emailId] = s; + continue; + } + // Cancelled goes back to Drafts; sent (or a submission the server no + // longer knows about) goes to Sent, which is where it actually is. + const toDrafts = s?.undoStatus === "canceled"; + const dest = toDrafts ? draftsId : sentId; + if (!dest) continue; + update[emailId] = { + [`mailboxIds/${scheduledId}`]: null, + [`mailboxIds/${dest}`]: true, + ...(toDrafts ? { "keywords/$draft": true } : {}), + }; + } + set({ pending, loaded: true }); + if (Object.keys(update).length) { + await client.call("Email/set", { accountId, update }); + void mail.loadMailboxes(); + void mail.refreshList(); + } + } catch (err) { + toast.error(`Could not update the Scheduled folder: ${(err as Error).message}`); + } + }, +})); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index c83b2be..c2284ef 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -488,6 +488,8 @@ img { max-width: 100%; } .message-body .body-host { display: block; } .remote-banner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; padding: 8px 12px; background: var(--warn-soft); color: var(--warn); border-radius: var(--radius-sm); font-size: .9em; } .remote-banner button { color: inherit; font-weight: 700; text-decoration: underline; } +.scheduled-banner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; padding: 8px 12px; background: var(--accent-soft); color: var(--accent-soft-fg); border-radius: var(--radius-sm); font-size: .9em; } +.scheduled-banner button { color: inherit; font-weight: 700; text-decoration: underline; } .quote-toggle { display: inline-flex; align-items: center; gap: 4px; margin: 8px 0; padding: 2px 10px; border-radius: 999px; background: var(--bg-sunken); color: var(--fg-muted); font-size: 12px; border: 1px solid var(--border); } .quote-toggle:hover { background: var(--bg-active); } .attachments { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 16px 16px; } diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index 99be5e5..c038a02 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -14,6 +14,9 @@ import { attachmentIcon } from "../mail/MessageView"; import { keyboard } from "@/lib/keyboard"; import { useIsMobile } from "@/ui/misc"; import { toast } from "@/ui/toast"; +import { ScheduleDialog, ScheduleMenuItems } from "./SchedulePicker"; +import { scheduleSupported, scheduleWindowMs } from "@/store/scheduled"; +import { formatScheduleTime } from "@/lib/schedule"; export function Composer({ draft }: { draft: Draft }) { const update = useCompose((s) => s.update); @@ -36,6 +39,10 @@ export function Composer({ draft }: { draft: Draft }) { const sendMenu = useMenu(); const templateMenu = useMenu(); const [showToolbar, setShowToolbar] = useState(true); + const [scheduleOpen, setScheduleOpen] = useState(false); + // Read once per render from the session; it cannot change while a composer is open. + const canSchedule = scheduleSupported(); + const scheduleMax = canSchedule ? scheduleWindowMs() : 0; const d = draft; const key = d.key; // Where the caret starts, decided once when the composer opens: a blank @@ -90,6 +97,12 @@ export function Composer({ draft }: { draft: Draft }) { await send(key); }; + const scheduleFor = (at: Date) => { + sendMenu.close(); + setScheduleOpen(false); + patch({ sendAt: at.getTime() }); + }; + const toggleFormat = () => { if (d.format === "html") { patch({ format: "text", text: htmlToText(d.html) }); @@ -173,6 +186,11 @@ export function Composer({ draft }: { draft: Draft }) { patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} /> {d.priority !== "normal" && {d.priority === "high" ? "High priority" : "Low priority"}} {d.requestReceipt && } + {d.sendAt !== null && ( + + )} {d.format === "html" ? ( @@ -197,13 +215,19 @@ export function Composer({ draft }: { draft: Draft }) { )}
- + - - } label="Send" kbd="Ctrl+↵" onClick={() => void doSend()} /> + + } label={d.sendAt !== null ? "Send now instead" : "Send"} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} /> } label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} /> + {canSchedule && { sendMenu.close(); setScheduleOpen(true); }} />} + {canSchedule && scheduleOpen && ( + setScheduleOpen(false)} onPick={scheduleFor} /> + )} { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} /> diff --git a/web/src/views/compose/SchedulePicker.tsx b/web/src/views/compose/SchedulePicker.tsx new file mode 100644 index 0000000..309242b --- /dev/null +++ b/web/src/views/compose/SchedulePicker.tsx @@ -0,0 +1,66 @@ +import { useMemo, useState } from "react"; +import { Clock } from "lucide-react"; +import { Dialog } from "@/ui/dialog"; +import { DateTimeField } from "@/ui/datefield"; +import { MenuItem, MenuSep, MenuTitle } from "@/ui/popover"; +import { describeSpan, formatScheduleTime, schedulePresets, scheduleError } from "@/lib/schedule"; +import { toInputDateTime, fromInputDateTime, roundToNext } from "@/lib/dates"; + +/** + * The quick picks that hang off the composer's send menu. Anything the server + * will not hold that long is simply not offered. + */ +export function ScheduleMenuItems({ maxMs, onPick, onCustom }: { maxMs: number; onPick: (at: Date) => void; onCustom: () => void }) { + const presets = useMemo(() => schedulePresets(new Date(), maxMs), [maxMs]); + return ( + <> + + Schedule send + {presets.map((p) => ( + } label={p.label} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} /> + ))} + } label="Pick date and time…" onClick={onCustom} /> + + ); +} + +/** The custom date/time dialog behind "Pick date and time…". */ +export function ScheduleDialog({ open, maxMs, initial, onClose, onPick }: { + open: boolean; + maxMs: number; + initial: number | null; + onClose: () => void; + onPick: (at: Date) => void; +}) { + const [value, setValue] = useState(() => toInputDateTime(initial ? new Date(initial) : roundToNext(new Date(Date.now() + 3_600_000), 15))); + const at = fromInputDateTime(value); + const error = scheduleError(at, new Date(), maxMs); + + return ( + + + + + } + > +
+ + +
+ {error ? ( +

{error}

+ ) : ( +

+ The message waits on the server, so it goes out whether or not ihasmail is open. + {maxMs > 0 && ` This server holds a message for up to ${describeSpan(maxMs)}.`} +

+ )} +
+ ); +} diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index 3b96b43..e854d8b 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -14,6 +14,7 @@ import { LabelPicker } from "./LabelPicker"; import type { Id } from "@/jmap/types"; import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; +import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled"; export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) { const [, navigate] = useLocation(); @@ -28,6 +29,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; const [focusId, setFocusId] = useState(null); const [movePicker, setMovePicker] = useState<{ ids: Id[] } | null>(null); const [labelPicker, setLabelPicker] = useState<{ ids: Id[]; anchor: { x: number; y: number } } | null>(null); + const reconcile = useScheduled((s) => s.reconcile); + const scheduledId = useMail((s) => scheduledMailboxIdFrom(s.mailboxes)); const q = useMemo(() => (search ? (new URLSearchParams(searchStr).get("q") ?? "") : ""), [search, searchStr]); @@ -47,14 +50,23 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; } if (!mailboxId) return null; const mb = mailboxes[mailboxId]; - const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent"; + // Scheduled joins Drafts and Sent as a folder of individual messages: they + // are outgoing, and collapsing them into their threads hides them. + const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent" || mailboxId === scheduledId; return { key: "", filter: { inMailbox: mailboxId }, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId }; - }, [search, q, mailboxId, mailboxes, settings.conversationMode]); + }, [search, q, mailboxId, mailboxes, settings.conversationMode, scheduledId]); useEffect(() => { if (listQuery && mailboxesLoaded) void query(listQuery); }, [listQuery, query, mailboxesLoaded]); + // Nothing moves a message out of Scheduled when its hold expires, so settle + // the folder up on the way in: sent messages to Sent, cancelled ones back to + // Drafts, and refresh what is still waiting. + useEffect(() => { + if (mailboxesLoaded && mailboxId && mailboxId === scheduledId) void reconcile(); + }, [mailboxId, scheduledId, mailboxesLoaded, reconcile]); + const openThread = useCallback( (tid: Id | null) => { const base = search ? `/search` : `/mail/${mailboxId}`; diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index b1485d9..ca755e5 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -1,7 +1,8 @@ import { useMemo, useState, type DragEvent, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { AlertOctagon, Archive, ChevronDown, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react"; +import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react"; import { useMail } from "@/store/mail"; +import { isScheduledMailbox } from "@/store/scheduled"; import { useSettings } from "@/store/settings"; import type { Id, Mailbox } from "@/jmap/types"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; @@ -128,11 +129,14 @@ export function MailboxTree() { function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void }) { const [dropping, setDropping] = useState(false); - const own = m.role === "drafts" ? m.totalEmails : m.unreadEmails; + // Scheduled counts like Drafts: everything in it is already read, so the + // useful number is how many messages are waiting, not how many are unseen. + const scheduled = isScheduledMailbox(m); + const own = m.role === "drafts" || scheduled ? m.totalEmails : m.unreadEmails; const count = own + hiddenUnread; // Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold). - const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts"; - const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : ; + const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" && !scheduled ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts" && !scheduled; + const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? : ; const onDragOver = (e: DragEvent) => { if (!e.dataTransfer.types.includes("application/x-ihasmail-emails")) return; diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 510050a..f96e78a 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react"; +import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Clock, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react"; import { FilterFromMessageDialog } from "./FilterFromMessage"; import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types"; import { useMail } from "@/store/mail"; @@ -20,6 +20,8 @@ import { InviteCard } from "./InviteCard"; import { VCardCard } from "./VCardCard"; import { AddressList, useAddressMenu } from "./AddressMenu"; import { useSession } from "@/store/session"; +import { useScheduled } from "@/store/scheduled"; +import { formatScheduleTime } from "@/lib/schedule"; interface Props { email: Email; @@ -47,6 +49,8 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email))); const remoteAllowed = allowRemote || settings.imagePolicy === "always" || senderTrusted || (settings.imagePolicy === "contacts" && inContacts); const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true); + const scheduled = useScheduled((s) => s.pending[e.id]); + const cancelScheduled = useScheduled((s) => s.cancel); const htmlPart = e.htmlBody?.[0]; const textPart = e.textBody?.[0]; @@ -200,6 +204,24 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog {receiptRequested && <>
Receipt
The sender requested a read receipt (not sent automatically).
} )} + {scheduled && ( +
+ + Waiting on the server — goes out {formatScheduleTime(new Date(scheduled.sendAt))}. + +
+ )} {rendered && rendered.remoteCount > 0 && !remoteAllowed && (