fix: serialize draft autosave with send to stop replies stalling in Drafts #303

This commit is contained in:
Linus Rath
2026-05-19 23:33:11 +02:00
parent 9a92271f6f
commit d530d9614b
2 changed files with 83 additions and 12 deletions
+60 -7
View File
@@ -320,9 +320,17 @@ export function EmailComposer({
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc()); const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null); const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
// Mirror of draftId for synchronous reads inside chained saves; React's
// setDraftId is async, so a queued saveDraft would otherwise see the old
// value and try to destroy a draft that was just replaced.
const draftIdRef = useRef<string | null>(initialData?.draftId ?? null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>(""); const lastSavedDataRef = useRef<string>("");
// Tracks the currently-running saveDraft so concurrent callers (autosave
// timer + send button) serialize instead of issuing parallel destroy/create
// requests with the same draftId. See bug #303.
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => { const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
if (mode === 'forward' && replyTo?.attachments?.length) { if (mode === 'forward' && replyTo?.attachments?.length) {
return replyTo.attachments return replyTo.attachments
@@ -897,7 +905,7 @@ export function EmailComposer({
}; };
// Auto-save draft functionality // Auto-save draft functionality
const saveDraft = async (): Promise<string | null> => { const saveDraftOnce = async (): Promise<string | null> => {
if (!client) return null; if (!client) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
@@ -923,7 +931,7 @@ export function EmailComposer({
// Only save if data has changed // Only save if data has changed
if (currentData === lastSavedDataRef.current) { if (currentData === lastSavedDataRef.current) {
return draftId; return draftIdRef.current;
} }
setSaveStatus('saving'); setSaveStatus('saving');
@@ -943,6 +951,7 @@ export function EmailComposer({
: (currentIdentity?.name || undefined); : (currentIdentity?.name || undefined);
try { try {
const previousDraftId = draftIdRef.current;
const savedDraftId = await client.createDraft( const savedDraftId = await client.createDraft(
toAddresses, toAddresses,
subject || t('no_subject'), subject || t('no_subject'),
@@ -951,12 +960,15 @@ export function EmailComposer({
bccAddresses, bccAddresses,
currentIdentity?.id, currentIdentity?.id,
fromEmail, fromEmail,
draftId || undefined, previousDraftId || undefined,
uploadedAttachments, uploadedAttachments,
fromName, fromName,
plainTextMode ? undefined : body plainTextMode ? undefined : body
); );
// Update the ref synchronously so a queued save sees the new id and
// doesn't try to destroy the just-replaced draft.
draftIdRef.current = savedDraftId;
setDraftId(savedDraftId); setDraftId(savedDraftId);
lastSavedDataRef.current = currentData; lastSavedDataRef.current = currentData;
setSaveStatus('saved'); setSaveStatus('saved');
@@ -973,6 +985,28 @@ export function EmailComposer({
} }
}; };
// Serialize saves: each call waits for the previous in-flight save before
// running. This prevents the autosave timer and the send button from
// racing two `Email/set { destroy, create }` requests against the same
// draftId, which left orphan drafts and (when EmailSubmission failed)
// looked like "send didn't happen" (#303).
const saveDraft = (): Promise<string | null> => {
const previous = inflightSaveRef.current;
const promise = (async (): Promise<string | null> => {
if (previous) {
try { await previous; } catch { /* prior failure already reported */ }
}
return saveDraftOnce();
})();
inflightSaveRef.current = promise;
promise.finally(() => {
if (inflightSaveRef.current === promise) {
inflightSaveRef.current = null;
}
});
return promise;
};
// Keep saveDraftRef pointing to latest saveDraft // Keep saveDraftRef pointing to latest saveDraft
saveDraftRef.current = saveDraft; saveDraftRef.current = saveDraft;
@@ -990,6 +1024,10 @@ export function EmailComposer({
// Set new timeout for auto-save (2 seconds after last change) // Set new timeout for auto-save (2 seconds after last change)
saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = setTimeout(() => {
// Clear the ref so handleSend can distinguish "save scheduled" from
// "save in flight" - the former still needs flushing, the latter is
// tracked via inflightSaveRef.
saveTimeoutRef.current = null;
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here. // Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({ emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean), to: to.split(',').map(s => s.trim()).filter(Boolean),
@@ -1113,14 +1151,28 @@ export function EmailComposer({
} }
} }
let finalDraftId = draftId; // Resolve the freshest draftId we can. Two cases:
// 1. An autosave is currently in flight - wait for it; don't issue a
// parallel destroy/create that would race with it on the same id.
// 2. A debounced save is scheduled (timer set) - cancel it and flush
// now so the latest body content lands on the server.
// Use draftIdRef (not the React state) because state updates from
// the in-flight save may not have rendered yet when we read here.
let finalDraftId = draftIdRef.current;
if (inflightSaveRef.current) {
try {
const savedId = await inflightSaveRef.current;
if (savedId) finalDraftId = savedId;
} catch (err) {
debug.error('In-flight draft save failed before send:', err);
}
}
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
try { try {
const savedId = await saveDraft(); const savedId = await saveDraft();
if (savedId) { if (savedId) finalDraftId = savedId;
finalDraftId = savedId;
}
} catch (err) { } catch (err) {
debug.error('Failed to save draft before send:', err); debug.error('Failed to save draft before send:', err);
} }
@@ -1383,6 +1435,7 @@ export function EmailComposer({
setBcc(""); setBcc("");
setSubject(""); setSubject("");
setBody(""); setBody("");
draftIdRef.current = null;
setDraftId(null); setDraftId(null);
setSubAddressTag(""); setSubAddressTag("");
setValidationErrors({}); setValidationErrors({});
+23 -5
View File
@@ -2236,15 +2236,33 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses) { if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) { for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) { if (methodName.endsWith('/error')) {
console.error('JMAP method error:', result); console.error('[sendEmail] JMAP method error:', methodName, result);
throw new Error(result.description || `Failed to send email: ${result.type}`); throw new Error(result.description || `Failed to send email: ${result.type}`);
} }
if (result.notCreated) { if (result.notCreated) {
const errors = result.notCreated; // Include method name + full error object so it's clear whether the
const firstError = Object.values(errors)[0] as { description?: string; type?: string }; // failure came from Email/set (draft create) or EmailSubmission/set
console.error('Email send error:', firstError); // (actual send) and which JMAP error type/properties were returned.
throw new Error(firstError?.description || firstError?.type || 'Failed to send email'); // Without this the user sees a generic "Failed to send" toast and
// the draft sits in Drafts with no indication of why (#303).
const errors = result.notCreated as Record<string, {
type?: string;
description?: string;
properties?: string[];
}>;
const firstError = Object.values(errors)[0];
console.error(
`[sendEmail] ${methodName} notCreated:`,
JSON.stringify(errors, null, 2),
);
const propsHint = firstError?.properties?.length
? ` (properties: ${firstError.properties.join(', ')})`
: '';
const typeHint = firstError?.type ? ` [${firstError.type}]` : '';
throw new Error(
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
);
} }
} }
} }