feat: drafts with autosave and a save/discard close guard, Bcc, signatures, templates, sub-addressing, From override, read receipts, upload progress, inline-image hydration and an undo-send snackbar in the composer (#188, #849, #848, #540, #570, #684, #163)

This commit is contained in:
Linus Rath
2026-08-29 18:28:38 +02:00
parent 3194b5ece1
commit 5b72c4d314
9 changed files with 2154 additions and 552 deletions
+4 -4
View File
@@ -18,7 +18,7 @@
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-clipboard": "^8.0.7",
"expo-clipboard": "~8.0.8",
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
@@ -5238,9 +5238,9 @@
}
},
"node_modules/expo-clipboard": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
+1 -1
View File
@@ -26,7 +26,7 @@
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-clipboard": "^8.0.7",
"expo-clipboard": "~8.0.8",
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
+84 -8
View File
@@ -6,6 +6,7 @@ import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './typ
import { toWildcardQuery } from '../lib/search-utils';
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
import { buildMdnMessage, type MdnOptions } from '../lib/mdn';
export const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
@@ -989,6 +990,12 @@ export interface OutgoingEmail {
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
messageId?: string;
requestReadReceipt?: boolean;
/**
* SMTP MAIL FROM when it must differ from the header From - a catch-all
* alias on an owned domain is sent through the identity's envelope while
* the visible From keeps the alias (webmail #246).
*/
envelopeMailFrom?: string;
}
export interface SendEmailResult {
@@ -1128,19 +1135,19 @@ export async function sendEmail(
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
// For a deferred send the envelope must be set explicitly so the HOLDFOR
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
// server derive mailFrom from the Identity, dropping our parameter).
if (holdForSeconds && holdForSeconds > 0) {
// server derive mailFrom from the Identity, dropping our parameter). An
// explicit envelope sender (catch-all From override) needs it as well.
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
if (holdFor > 0 || email.envelopeMailFrom) {
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
.map((r) => r.email.trim())
.filter(Boolean)
.map((address) => ({ email: address }));
submissionCreate.envelope = {
mailFrom: {
email: email.from[0]?.email,
parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) },
},
rcptTo,
const mailFrom: Record<string, unknown> = {
email: email.envelopeMailFrom || email.from[0]?.email,
};
if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) };
submissionCreate.envelope = { mailFrom, rcptTo };
}
const submissionArgs: Record<string, unknown> = {
@@ -1402,3 +1409,72 @@ export async function rescheduleScheduledSend(
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
return { emailSubmissionId: created?.id, sendAt: created?.sendAt };
}
/**
* Move a message back into Drafts (out of Sent) and re-flag it `$draft` so
* it can be edited and re-sent - used after cancelling a scheduled send for
* editing (webmail `restoreEmailToDraft`).
*/
export async function restoreEmailToDraft(
emailId: string,
draftsMailboxId: string,
sentMailboxId?: string,
accountIdOverride?: string,
): Promise<void> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const patch: Record<string, unknown> = {
[mailboxPointer(draftsMailboxId)]: true,
[keywordPointer('$draft')]: true,
[keywordPointer('$seen')]: true,
};
if (sentMailboxId && sentMailboxId !== draftsMailboxId) patch[mailboxPointer(sentMailboxId)] = null;
await emailSetBatched(accountId, { update: { [emailId]: patch } }, 'draft');
}
export interface SendReadReceiptOptions extends MdnOptions {
/** Identity the receipt is submitted through (its address is the MDN From). */
identityId: string;
/** Where the sent receipt is filed. */
sentMailboxId: string;
/** Submitting account (shared/group account); defaults to the primary. */
accountId?: string;
}
/**
* Send an RFC 8098 read receipt (MDN). JMAP has no MDN primitive, so the
* multipart/report is built client-side (`lib/mdn.ts`), uploaded as a blob,
* imported into Sent and submitted with an explicit envelope - mirrors the
* webmail's `client.sendReadReceipt`. The caller flags the original
* `$mdnsent` afterwards.
*/
export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise<string> {
const accountId = opts.accountId ?? jmapClient.accountId;
const raw = buildMdnMessage(opts);
const bytes = new TextEncoder().encode(raw);
// Lazy: blob.ts pulls in expo-file-system, which this module otherwise
// never needs (and which the node test environment cannot load).
const { uploadBytes } = await import('./blob');
const upload = await uploadBytes(bytes, 'message/rfc822', accountId);
const emailId = await importEmailBlob(upload.blobId, opts.sentMailboxId, { $seen: true }, accountId);
const res = await jmapClient.request(
[
['EmailSubmission/set', {
accountId,
create: {
mdn: {
emailId,
identityId: opts.identityId,
envelope: {
mailFrom: { email: opts.fromEmail },
rcptTo: [{ email: opts.to }],
},
},
},
}, '0'],
],
SUBMISSION_USING,
);
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
assertSetResult(body, ['mdn'], 'read receipt');
return emailId;
}
+27 -2
View File
@@ -21,7 +21,9 @@ export type RichTextCommand =
| 'justifyRight'
| 'removeFormat'
| 'undo'
| 'redo';
| 'redo'
// Text colour: a CSS colour after the colon, e.g. 'foreColor:#d97706'.
| `foreColor:${string}`;
export interface RichTextSelectionState {
bold: boolean;
@@ -50,6 +52,8 @@ export interface RichTextEditorHandle {
insertLink(url: string, label?: string): void;
unsetLink(): void;
insertImage(src: string, cid?: string, alt?: string): void;
/** Insert an HTML fragment at the caret (template bodies, tables). */
insertHtml(html: string): void;
setHtml(html: string): void;
/**
* Read the live editor DOM content. Resolves with the current innerHTML or
@@ -97,7 +101,17 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
[],
);
// Scripts injected before the page has run its inline script are lost, and
// the composer talks to the editor as soon as async data arrives (signature
// after the identities load, hydrated inline images). Queue until the page
// posts its first message, then replay in order.
const readyRef = React.useRef(false);
const queuedRef = React.useRef<string[]>([]);
const call = React.useCallback((script: string) => {
if (!readyRef.current) {
queuedRef.current.push(script);
return;
}
webViewRef.current?.injectJavaScript(`${script}; true;`);
}, []);
@@ -124,7 +138,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
React.useImperativeHandle(ref, () => ({
exec: (command) => {
const [name, value] = command.split(':');
const sep = command.indexOf(':');
const name = sep === -1 ? command : command.slice(0, sep);
const value = sep === -1 ? undefined : command.slice(sep + 1);
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
},
@@ -137,6 +153,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
insertImage: (src, cid, alt) => {
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
},
insertHtml: (html) => {
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`);
},
setHtml: (next) => {
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
},
@@ -162,6 +181,12 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
} catch {
return;
}
if (!readyRef.current) {
readyRef.current = true;
const queued = queuedRef.current;
queuedRef.current = [];
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
}
switch (data.type) {
case 'change':
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
+199
View File
@@ -0,0 +1,199 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Star, FileText } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store';
import { filterTemplates } from '../lib/template-utils';
interface TemplateSheetProps {
visible: boolean;
onClose: () => void;
onPick: (template: EmailTemplate) => void;
}
/**
* Bottom sheet listing the user's templates for insertion into the composer
* (webmail template picker): favourites first, then the rest, with a search
* box over name/subject/category.
*/
export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
const templates = useTemplatesStore((s) => s.templates);
const hydrated = useTemplatesStore((s) => s.hydrated);
const hydrate = useTemplatesStore((s) => s.hydrate);
const [query, setQuery] = React.useState('');
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
React.useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
React.useEffect(() => {
if (visible) {
setQuery('');
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const visibleTemplates = React.useMemo(() => {
const list = query.trim() ? filterTemplates(templates, query.trim()) : templates;
return [...list].sort((a, b) => {
if (a.isFavorite !== b.isFavorite) return a.isFavorite ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, [templates, query]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('templates.picker_title', 'Choose a Template')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
{templates.length > 4 && (
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t('templates.search_placeholder', 'Search templates...')}
placeholderTextColor={c.textMuted}
style={styles.search}
autoCorrect={false}
/>
)}
<ScrollView style={styles.scrollList} keyboardShouldPersistTaps="handled">
{visibleTemplates.length === 0 ? (
<Text style={styles.empty}>
{templates.length === 0
? t('templates.no_templates', 'No templates yet')
: t('templates.no_results', 'No templates found')}
</Text>
) : visibleTemplates.map((template) => (
<Pressable
key={template.id}
onPress={() => {
onPick(template);
onClose();
}}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={styles.iconWrap}>
{template.isFavorite
? <Star size={16} color={c.primary} />
: <FileText size={16} color={c.textSecondary} />}
</View>
<View style={styles.details}>
<Text style={styles.name} numberOfLines={1}>{template.name}</Text>
{!!(template.subject || template.category) && (
<Text style={styles.subject} numberOfLines={1}>
{[template.category, template.subject].filter(Boolean).join(' · ')}
</Text>
)}
</View>
</Pressable>
))}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sheetOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheetOverlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '70%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetHandleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
sheetHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
sheetTitle: { ...typography.bodySemibold, color: c.text },
sheetClose: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
search: {
...typography.body,
color: c.text,
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
scrollList: { paddingVertical: spacing.xs },
empty: { ...typography.body, color: c.textMuted, textAlign: 'center', padding: spacing.xl },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
minHeight: 52,
},
rowPressed: { backgroundColor: c.surfaceHover },
iconWrap: {
marginRight: spacing.md,
width: 28,
height: 28,
borderRadius: radius.xs,
backgroundColor: c.surface,
alignItems: 'center',
justifyContent: 'center',
},
details: { flex: 1, justifyContent: 'center' },
name: { ...typography.bodyMedium, color: c.text },
subject: { ...typography.caption, color: c.textSecondary, marginTop: 2 },
});
}
+96 -12
View File
@@ -1,33 +1,72 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useEmailStore } from '../stores/email-store';
import { useSendUndoStore, type PendingUndoSend } from '../stores/send-undo-store';
import { useLocaleStore } from '../stores/locale-store';
import { getFullEmail, restoreEmailToDraft } from '../api/email';
import { ownMailboxes } from '../lib/mailbox-tree';
import { draftContextFromEmail } from '../lib/draft-context';
import type { RootStackParamList } from '../navigation/types';
const VISIBLE_MS = 5000;
interface Shown {
label: string;
createdAt: number;
visibleMs: number;
/** Undo-send entries carry a second "Send now" action. */
send?: PendingUndoSend;
}
export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const entry = useEmailStore((s) => s.pendingUndo);
const undoLast = useEmailStore((s) => s.undoLast);
const clearUndo = useEmailStore((s) => s.clearUndo);
const pendingSend = useSendUndoStore((s) => s.pending);
const sendBusy = useSendUndoStore((s) => s.busy);
const slideY = React.useRef(new Animated.Value(120)).current;
const opacity = React.useRef(new Animated.Value(0)).current;
// The undo-send entry (composer) and the list-action entry (store) share
// the bar; the newer one wins.
const current = React.useMemo<Shown | null>(() => {
const list: Shown | null = entry
? { label: entry.label, createdAt: entry.createdAt, visibleMs: VISIBLE_MS }
: null;
const send: Shown | null = pendingSend
? {
label: t('email_composer.undo_send_label', 'Sending in {seconds}s…', { seconds: pendingSend.delaySeconds }),
createdAt: pendingSend.createdAt,
visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000),
send: pendingSend,
}
: null;
if (list && send) return list.createdAt >= send.createdAt ? list : send;
return list ?? send;
}, [entry, pendingSend, t]);
// Remember the last non-null entry so the bar's text/handlers stay valid
// while it animates out after pendingUndo flips to null.
const [shown, setShown] = React.useState(entry);
// while it animates out after the entry flips to null.
const [shown, setShown] = React.useState(current);
React.useEffect(() => {
if (entry) setShown(entry);
}, [entry]);
if (current) setShown(current);
}, [current]);
// Drive the auto-dismiss off the entry's createdAt so re-renders during
// animation don't reset the timer, and so consecutive actions reset it.
React.useEffect(() => {
if (!entry) {
if (!current) {
Animated.parallel([
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
@@ -42,16 +81,44 @@ export function UndoSnackbar() {
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
const elapsed = Date.now() - entry.createdAt;
const remaining = Math.max(0, VISIBLE_MS - elapsed);
const elapsed = Date.now() - current.createdAt;
const remaining = Math.max(0, current.visibleMs - elapsed);
const id = setTimeout(() => {
// Only clear if the entry is still the same one we scheduled for.
if (useEmailStore.getState().pendingUndo === entry) {
if (current.send) {
if (useSendUndoStore.getState().pending === current.send) useSendUndoStore.getState().clear();
} else if (useEmailStore.getState().pendingUndo === entry) {
// Only clear if the entry is still the same one we scheduled for.
clearUndo();
}
}, remaining);
return () => clearTimeout(id);
}, [entry, slideY, opacity, clearUndo]);
}, [current, entry, slideY, opacity, clearUndo]);
const onUndo = async () => {
if (!shown) return;
if (!shown.send) {
void undoLast();
return;
}
const send = shown.send;
const ok = await useSendUndoStore.getState().undo();
if (!ok) return;
// Bring the message back as an editable draft and reopen the composer.
try {
const own = ownMailboxes(useEmailStore.getState().mailboxes);
const drafts = own.find((m) => m.role === 'drafts');
const sent = own.find((m) => m.role === 'sent');
if (drafts) await restoreEmailToDraft(send.emailId, drafts.id, sent?.id);
const email = await getFullEmail(send.emailId);
navigation.navigate('Compose', { draft: draftContextFromEmail(email) });
} catch (err) {
console.warn('[send-undo] reopen failed', err);
}
};
const onSendNow = () => {
void useSendUndoStore.getState().sendNow();
};
if (!shown) return null;
@@ -65,12 +132,23 @@ export function UndoSnackbar() {
>
<View style={styles.bar}>
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
{shown.send && (
<Pressable
onPress={onSendNow}
hitSlop={8}
disabled={sendBusy}
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
>
<Text style={styles.sendNowText}>{t('email_composer.send_now', 'Send now').toUpperCase()}</Text>
</Pressable>
)}
<Pressable
onPress={() => { void undoLast(); }}
onPress={() => { void onUndo(); }}
hitSlop={8}
disabled={sendBusy}
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
>
<Text style={styles.undoText}>UNDO</Text>
<Text style={styles.undoText}>{t('common.undo', 'Undo').toUpperCase()}</Text>
</Pressable>
</View>
</Animated.View>
@@ -117,5 +195,11 @@ function makeStyles(c: ThemePalette) {
color: c.primary,
letterSpacing: 0.5,
},
sendNowText: {
...typography.bodySemibold,
color: c.background,
letterSpacing: 0.5,
opacity: 0.85,
},
});
}
+1 -1
View File
@@ -80,7 +80,7 @@ export function buildEditorHtml(opts: {
</style>
</head>
<body>
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
<div id="editor" contenteditable="true" dir="auto" data-placeholder=${placeholderJson}></div>
<script>
(function () {
var editor = document.getElementById('editor');
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
// A message sent with the undo-send delay: the submission is held by the
// server (HOLDFOR) and can still be cancelled or released early. The composer
// records it here after a delayed send; the UndoSnackbar on the mail list
// offers "Undo" / "Send now" for the length of the window (webmail
// `pendingUndoSend`, changelog 1.7.0).
import { create } from 'zustand';
import { cancelScheduledSend, rescheduleScheduledSend } from '../api/email';
import type { EmailAddress } from '../api/types';
export interface PendingUndoSend {
emailSubmissionId: string;
emailId: string;
identityId: string;
from?: EmailAddress[];
to?: EmailAddress[];
/** ISO time the server will release the message. */
sendAt?: string;
/** Milliseconds the message is held (drives the snackbar timer). */
delaySeconds: number;
createdAt: number;
}
interface SendUndoState {
pending: PendingUndoSend | null;
busy: boolean;
/** Set after an undo so the composer can be reopened with the draft. */
restoredEmailId: string | null;
setPending: (entry: PendingUndoSend) => void;
clear: () => void;
/** Cancel delivery. The message stays in Sent; the caller may re-open it. */
undo: () => Promise<boolean>;
/** Release the held message immediately. */
sendNow: () => Promise<boolean>;
}
export const useSendUndoStore = create<SendUndoState>((set, get) => ({
pending: null,
busy: false,
restoredEmailId: null,
setPending: (entry) => set({ pending: entry, restoredEmailId: null }),
clear: () => set({ pending: null, busy: false }),
undo: async () => {
const entry = get().pending;
if (!entry || get().busy) return false;
set({ busy: true });
try {
await cancelScheduledSend(entry.emailSubmissionId);
set({ pending: null, busy: false, restoredEmailId: entry.emailId });
return true;
} catch (err) {
console.warn('[send-undo] cancel failed', err);
set({ busy: false });
return false;
}
},
sendNow: async () => {
const entry = get().pending;
if (!entry || get().busy) return false;
set({ busy: true });
try {
await rescheduleScheduledSend(
{
emailSubmissionId: entry.emailSubmissionId,
emailId: entry.emailId,
identityId: entry.identityId,
from: entry.from,
to: entry.to,
},
0,
);
set({ pending: null, busy: false });
return true;
} catch (err) {
console.warn('[send-undo] send now failed', err);
set({ busy: false });
return false;
}
},
}));