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