Merge scheduled send

Both branches turn on where Stalwart advertises a capability, so they meet
in the same two files. The mock keeps `urn:stalwart:jmap` out of the
session-level capabilities and hands it out per-account, as a real server
does, while the submission capability it grew for scheduled send lives
per-account beside it; the client keeps both accessors, one asking whether a
capability is advertised anywhere and one reading the object itself.
This commit is contained in:
2026-08-24 22:24:13 -07:00
21 changed files with 1227 additions and 38 deletions
+4
View File
@@ -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();
+10
View File
@@ -137,6 +137,16 @@ export class JmapClient {
return Object.values(this.session?.accounts ?? {}).some((a) => cap in (a.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<T>(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;
}
+120
View File
@@ -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");
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Scheduled send, as Stalwart actually implements it.
*
* JMAP does not let a client set `sendAt` directly -- RFC 8621 makes it a
* server-derived property. The hold is requested through the SMTP
* FUTURERELEASE extension (RFC 4865) instead, by putting a `HOLDUNTIL`
* parameter on the envelope's `mailFrom`; the server parses it, holds the
* message in its queue, and reports back the `sendAt` it settled on.
*
* Stalwart advertises the extension per-account rather than session-wide, so
* the capability has to be read out of `accountCapabilities`, not the
* top-level `capabilities` (where it is an empty object).
*/
import { addDays, startOfDay } from "./dates";
import { formatFullDateTime } from "./datetime";
export const SUBMISSION_CAP = "urn:ietf:params:jmap:submission";
/** The account's `urn:ietf:params:jmap:submission` capability object. */
export interface SubmissionCapability {
maxDelayedSend?: number;
submissionExtensions?: Record<string, string[]>;
}
/**
* Whether the server will hold a message for us. Both halves matter: a server
* may advertise the submission capability with `maxDelayedSend: 0`, which RFC
* 8621 defines as "delayed sending is not supported".
*/
export function canScheduleSend(cap: SubmissionCapability | undefined | null): boolean {
if (!cap) return false;
const max = typeof cap.maxDelayedSend === "number" ? cap.maxDelayedSend : 0;
const exts = cap.submissionExtensions ?? {};
return max > 0 && Object.prototype.hasOwnProperty.call(exts, "FUTURERELEASE");
}
/** How far ahead this server will hold a message, in milliseconds. */
export function maxDelayMs(cap: SubmissionCapability | undefined | null): number {
const max = cap && typeof cap.maxDelayedSend === "number" ? cap.maxDelayedSend : 0;
return Math.max(0, max) * 1000;
}
/**
* The `HOLDUNTIL` parameter value. Stalwart parses this with its RFC 5321
* parameter parser and wants an RFC 3339 date-time; it briefly wanted a Unix
* timestamp instead, which was a bug fixed in 0.16.17.
*
* Seconds are truncated because the queue works in whole seconds anyway, and a
* value carrying milliseconds only makes the round-tripped `sendAt` disagree
* with what we asked for.
*/
export function holdUntil(at: Date): string {
return new Date(Math.floor(at.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
}
export interface SchedulePreset {
id: string;
label: string;
at: Date;
}
/** The soonest we will offer to schedule: anything closer is just "Send". */
export const MIN_LEAD_MS = 60_000;
function at(day: Date, hour: number): Date {
const d = startOfDay(day);
d.setHours(hour, 0, 0, 0);
return d;
}
/**
* Gmail-style quick picks, minus any that have already passed or that fall
* outside what the server will hold. "Later today" only appears while there is
* still enough of the day left for it to mean anything.
*/
export function schedulePresets(now: Date, maxMs: number): SchedulePreset[] {
const monday = (() => {
// Next Monday; if today is Monday, the Monday a week out.
const days = (8 - now.getDay()) % 7 || 7;
return at(addDays(now, days), 8);
})();
const all: SchedulePreset[] = [
{ id: "later-today", label: "Later today", at: at(now, 17) },
{ id: "tomorrow-morning", label: "Tomorrow morning", at: at(addDays(now, 1), 8) },
{ id: "tomorrow-afternoon", label: "Tomorrow afternoon", at: at(addDays(now, 1), 13) },
{ id: "monday-morning", label: "Monday morning", at: monday },
];
const floor = now.getTime() + MIN_LEAD_MS;
const ceiling = now.getTime() + maxMs;
return all.filter((p) => p.at.getTime() >= floor && p.at.getTime() <= ceiling);
}
/**
* Why this instant will not do, or null if it will. The upper bound is the
* server's own -- exceeding it makes Stalwart reject MAIL FROM outright, which
* surfaces as a failed send rather than anything the user can act on.
*/
export function scheduleError(at: Date, now: Date, maxMs: number): string | null {
const t = at.getTime();
if (Number.isNaN(t)) return "Pick a date and time.";
if (t < now.getTime() + MIN_LEAD_MS) return "Pick a time at least a minute from now.";
if (maxMs > 0 && t > now.getTime() + maxMs) {
return `This server will not hold a message longer than ${describeSpan(maxMs)}.`;
}
return null;
}
/** "30 days", "7 days", "12 hours" -- for explaining the server's own limit. */
export function describeSpan(ms: number): string {
const days = Math.floor(ms / 86_400_000);
if (days >= 1) return `${days} day${days === 1 ? "" : "s"}`;
const hours = Math.max(1, Math.floor(ms / 3_600_000));
return `${hours} hour${hours === 1 ? "" : "s"}`;
}
/** How a scheduled time reads in menus, banners and toasts. */
export function formatScheduleTime(at: Date): string {
return formatFullDateTime(at);
}
@@ -18,7 +18,7 @@ function draft(over: Partial<Draft> = {}): 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,
};
}
@@ -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: "[email protected]",
emailRef: "#m",
rcpts: [{ email: "[email protected]" }],
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<string, unknown> };
expect(envelope.mailFrom).toEqual({ email: "[email protected]" });
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<string, string> } };
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<string, string> } };
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: "[email protected]" }, { email: "[email protected]" }],
sendAt: null,
});
expect(create.identityId).toBe("i1");
expect(create.emailId).toBe("#m");
expect((create.envelope as { rcptTo: unknown[] }).rcptTo).toEqual([{ email: "[email protected]" }, { email: "[email protected]" }]);
});
});
@@ -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<string, Record<string, unknown>>[] = [];
const submissionUpdates: Record<string, Record<string, unknown>>[] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, 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<string, Record<string, unknown>>);
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<string, Record<string, unknown>>);
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<string, unknown>) {
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/);
});
});
+72 -13
View File
@@ -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> = {}): Draft {
error: null,
signatureHtml: "",
replyMode: null,
sendAt: null,
...init,
};
}
@@ -378,6 +383,8 @@ export const useCompose = create<ComposeState>((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<ComposeState>((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<ComposeState>((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<Record<string, unknown>> {
export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailboxId?: Id | null }): Promise<Record<string, unknown>> {
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<string, unknown>; onSuccessUpdateEmail: Record<string, unknown> } {
const scheduled = opts.sendAt !== null;
const mailFrom: Record<string, unknown> = { email: opts.fromEmail };
if (scheduled) mailFrom.parameters = { HOLDUNTIL: holdUntil(new Date(opts.sendAt!)) };
const filedIn = scheduled ? opts.scheduledId : opts.sentId;
const onSuccess: Record<string, unknown> = { "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<void> {
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<string, unknown> = { "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, unknown>, 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<void> {
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();
}
+238
View File
@@ -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<SubmissionCapability>(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<Mailbox, "role" | "parentId" | "name">): 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, Mailbox>): 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<Id> {
const existing = scheduledMailboxId();
if (existing) return existing;
const accountId = useMail.getState().accountId!;
const res = await client.call<SetResponse<Mailbox>>("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<Id, PendingSend>;
loaded: boolean;
load(): Promise<void>;
cancel(emailId: Id): Promise<void>;
reconcile(): Promise<void>;
}
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<PendingSend[]> {
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<EmailSubmission> | 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<PendingSend[]> {
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<EmailSubmission> | undefined;
return (got?.list ?? []).map(toPending);
}
export const useScheduled = create<ScheduledState>((set, get) => ({
pending: {},
loaded: false,
async load() {
const accountId = useMail.getState().accountId;
if (!accountId) return;
try {
const list = await loadPending(accountId);
const pending: Record<Id, PendingSend> = {};
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<QueryResponse>("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<Id, PendingSend>();
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<Id, Record<string, unknown>> = {};
const pending: Record<Id, PendingSend> = {};
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}`);
}
},
}));
+2
View File
@@ -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; }
+27 -3
View File
@@ -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 }) {
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} />
{d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>}
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
{d.sendAt !== null && (
<button type="button" className="tag" style={{ background: "var(--accent)" }} title="Scheduled — click to clear the schedule" onClick={() => patch({ sendAt: null })}>
<Clock size={12} /> {formatScheduleTime(new Date(d.sendAt))} <X size={12} />
</button>
)}
</div>
</div>
{d.format === "html" ? (
@@ -197,13 +215,19 @@ export function Composer({ draft }: { draft: Draft }) {
)}
<div className="composer-foot">
<span className="send-group">
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title="Send (Ctrl+Enter)"><Send size={16} /> Send</button>
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title={d.sendAt !== null ? `Hand to the server, held until ${formatScheduleTime(new Date(d.sendAt))} (Ctrl+Enter)` : "Send (Ctrl+Enter)"}>
{d.sendAt !== null ? <><Clock size={16} /> Schedule send</> : <><Send size={16} /> Send</>}
</button>
<button className="btn btn-primary" onClick={sendMenu.open} aria-label="Send options"><ChevronDown size={16} /></button>
</span>
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={240}>
<MenuItem icon={<Send size={16} />} label="Send" kbd="Ctrl+↵" onClick={() => void doSend()} />
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={280}>
<MenuItem icon={<Send size={16} />} 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(); }} />
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
</Popover>
{canSchedule && scheduleOpen && (
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
)}
<span className="more-actions">
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
+66
View File
@@ -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 (
<>
<MenuSep />
<MenuTitle>Schedule send</MenuTitle>
{presets.map((p) => (
<MenuItem key={p.id} icon={<Clock size={16} />} label={p.label} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} />
))}
<MenuItem icon={<Clock size={16} />} 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 (
<Dialog
open={open}
onClose={onClose}
title="Schedule send"
size="sm"
footer={
<>
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" disabled={Boolean(error)} onClick={() => onPick(at)}>Schedule send</button>
</>
}
>
<div className="field">
<label htmlFor="schedule-at">Send at</label>
<DateTimeField id="schedule-at" value={value} onChange={setValue} aria-label="Date and time to send" />
</div>
{error ? (
<p className="hint" style={{ color: "var(--danger)" }}>{error}</p>
) : (
<p className="hint">
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)}.`}
</p>
)}
</Dialog>
);
}
+14 -2
View File
@@ -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<Id | null>(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}`;
+8 -4
View File
@@ -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] : <Folder size={20} />;
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 ? <Clock size={20} /> : <Folder size={20} />;
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
+23 -1
View File
@@ -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 && <><dt>Receipt</dt><dd>The sender requested a read receipt (not sent automatically).</dd></>}
</dl>
)}
{scheduled && (
<div className="scheduled-banner" style={{ margin: "0 16px 8px" }}>
<Clock size={16} />
<span className="grow">Waiting on the server goes out {formatScheduleTime(new Date(scheduled.sendAt))}.</span>
<button
onClick={async () => {
try {
await cancelScheduled(e.id);
toast.success("Send cancelled — the message is back in Drafts");
} catch (err) {
toast.error(`Could not cancel: ${(err as Error).message}`);
}
}}
>
Cancel send
</button>
</div>
)}
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
<ImageIcon size={16} />