fix(compose): wait for in-flight attachment uploads before sending

Clicking Send while attachments were still uploading silently dropped
them from the outgoing message: every place that builds the outgoing
attachment list filters on att.blobId && !att.uploading, and the Send
button never accounted for uploads still in flight. Attach a few files,
hit Send right away, and the email could go out missing some of them
with no warning.

handleSend now detects pending uploads before validating:

- Send is disabled with an explanatory tooltip
  (validation.attachments_uploading) while it waits.
- Once uploads finish cleanly, the send proceeds automatically - no
  second click needed.
- If an upload FAILS while waiting, the send is aborted with an error
  toast (validation.attachment_upload_failed) instead of silently
  shipping the email without the failed attachment - the user may not
  be looking at the composer to notice the red error chip.
- If the draft is closed or discarded while waiting, the pending send
  is cancelled cleanly.

The wait/decision logic lives in waitForPendingUploads() in
lib/email-composer-utils.ts (returns completed | cancelled | failed)
with unit tests covering all three outcomes. Outgoing-attachment
call sites read the freshest state via attachmentsRef since the
render closure captured at click time won't reflect uploads that
finished during the wait.

Both new i18n keys added to all 20 locales under
email_composer.validation.
This commit is contained in:
KazNIISA IT
2026-07-04 14:54:41 +02:00
committed by Linus Rath
parent 9aef8bc393
commit 396f5f7d60
23 changed files with 210 additions and 24 deletions
+44 -4
View File
@@ -45,6 +45,7 @@ import {
parseRecipientList,
formatRecipientList,
splitPastedRecipients,
waitForPendingUploads,
type Recipient,
} from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor";
@@ -1413,6 +1414,7 @@ export function EmailComposer({
const canSend = toAddresses.length > 0 && !!subject && hasContent;
const getSendTooltip = (): string | undefined => {
if (isWaitingForUploads) return t('validation.attachments_uploading');
if (canSend) return undefined;
if (toAddresses.length === 0) return t('validation.recipient_required');
if (!subject) return t('validation.subject_required');
@@ -1498,8 +1500,43 @@ export function EmailComposer({
const [isSending, setIsSending] = useState(false);
const isSendingRef = useRef(false);
// Attachments still uploading when Send is clicked used to be silently
// dropped from the outgoing message (the filters below exclude anything
// with uploading:true). attachmentsRef gives handleSend a way to read the
// freshest attachment state after waiting on in-flight uploads, since the
// `attachments` closure captured at click time won't reflect uploads that
// finish during that wait.
const attachmentsRef = useRef<ComposerAttachment[]>(attachments);
useEffect(() => {
attachmentsRef.current = attachments;
}, [attachments]);
const [isWaitingForUploads, setIsWaitingForUploads] = useState(false);
const sendCancelledRef = useRef(false);
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
if (isSendingRef.current) return;
if (attachmentsRef.current.some(att => att.uploading)) {
isSendingRef.current = true;
setIsSending(true);
setIsWaitingForUploads(true);
const uploadResult = await waitForPendingUploads(
() => attachmentsRef.current,
() => sendCancelledRef.current
);
setIsWaitingForUploads(false);
isSendingRef.current = false;
setIsSending(false);
if (uploadResult === 'cancelled') return;
if (uploadResult === 'failed') {
// An upload broke while we were waiting - the user may not be
// looking at the composer, so auto-sending would silently drop
// the failed attachment. Abort and let them decide.
toast.error(t('validation.attachment_upload_failed'));
return;
}
}
const ccAddresses = withInput(cc, ccInput);
const bccAddresses = withInput(bcc, bccInput);
@@ -1520,7 +1557,7 @@ export function EmailComposer({
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
const bodyText = htmlToPlainText(body);
const searchText = `${subject} ${bodyText}`.toLowerCase();
@@ -1636,7 +1673,7 @@ export function EmailComposer({
textBody: finalBody,
identityId: currentIdentity?.id || '',
fromEmail,
attachments: attachments
attachments: attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
@@ -1663,7 +1700,7 @@ export function EmailComposer({
references: threadingHeaders?.references,
delayedUntil: effectiveDelayedUntil,
attachments: [
...attachments
...attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })),
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
@@ -1680,7 +1717,7 @@ export function EmailComposer({
} else {
// Standard JMAP send path
// Collect uploaded attachment blobIds for the send request
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
@@ -1807,6 +1844,7 @@ export function EmailComposer({
}, []);
const cleanClose = () => {
sendCancelledRef.current = true;
explicitCloseRef.current = true;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -1816,6 +1854,7 @@ export function EmailComposer({
};
const handleSaveDraftAndClose = async () => {
sendCancelledRef.current = true;
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
@@ -1827,6 +1866,7 @@ export function EmailComposer({
};
const handleDiscardAndClose = () => {
sendCancelledRef.current = true;
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {