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) {
@@ -10,6 +10,7 @@ import {
parseRecipientList,
formatRecipientList,
splitPastedRecipients,
waitForPendingUploads,
} from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
@@ -274,3 +275,77 @@ describe("splitPastedRecipients", () => {
expect(splitPastedRecipients(" ")).toEqual({ valid: [], invalid: [] });
});
});
describe("waitForPendingUploads", () => {
const att = (over: Partial<{ uploading: boolean; error: boolean }> = {}) => ({
name: "file.pdf",
type: "application/pdf",
size: 100,
...over,
});
it("resolves 'completed' immediately when nothing is uploading", async () => {
const result = await waitForPendingUploads(
() => [att({}), att({})],
() => false,
1
);
expect(result).toBe("completed");
});
it("polls until in-flight uploads finish, then resolves 'completed'", async () => {
let list = [att({ uploading: true }), att({})];
setTimeout(() => {
list = [att({}), att({})];
}, 10);
const result = await waitForPendingUploads(() => list, () => false, 1);
expect(result).toBe("completed");
});
it("resolves 'failed' when an upload finishes with an error during the wait", async () => {
let list = [att({ uploading: true })];
setTimeout(() => {
list = [att({ error: true })];
}, 10);
const result = await waitForPendingUploads(() => list, () => false, 1);
expect(result).toBe("failed");
});
it("resolves 'failed' when another attachment is already errored once uploads finish", async () => {
let list = [att({ uploading: true }), att({ error: true })];
setTimeout(() => {
list = [att({}), att({ error: true })];
}, 10);
const result = await waitForPendingUploads(() => list, () => false, 1);
expect(result).toBe("failed");
});
it("resolves 'cancelled' when cancellation is signalled mid-wait", async () => {
let cancelled = false;
const list = [att({ uploading: true })];
setTimeout(() => {
cancelled = true;
}, 10);
const result = await waitForPendingUploads(
() => list,
() => cancelled,
1
);
expect(result).toBe("cancelled");
});
it("prefers 'cancelled' over 'failed' when the draft is closed while an errored upload is pending", async () => {
let cancelled = false;
let list = [att({ uploading: true })];
setTimeout(() => {
list = [att({ error: true, uploading: true })];
cancelled = true;
}, 10);
const result = await waitForPendingUploads(
() => list,
() => cancelled,
1
);
expect(result).toBe("cancelled");
});
});
+31
View File
@@ -235,3 +235,34 @@ export function replaceInlineImagePlaceholders(
});
return changed ? doc.body.innerHTML : html;
}
export type PendingUploadLike = {
uploading?: boolean;
error?: boolean;
};
export type PendingUploadWaitResult = "completed" | "cancelled" | "failed";
/**
* Wait for in-flight attachment uploads to settle before sending.
*
* Polls `getAttachments` until nothing is `uploading`, checking
* `isCancelled` between polls (composer closed / draft discarded).
* Resolves:
* - "cancelled" - cancellation was signalled while waiting
* - "failed" - uploads settled but at least one attachment errored;
* the caller must NOT auto-send (the user may not be
* looking at the composer to notice the failed chip)
* - "completed" - all uploads finished cleanly, safe to proceed
*/
export async function waitForPendingUploads(
getAttachments: () => readonly PendingUploadLike[],
isCancelled: () => boolean,
pollMs = 150
): Promise<PendingUploadWaitResult> {
while (getAttachments().some((att) => att.uploading)) {
if (isCancelled()) return "cancelled";
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
return getAttachments().some((att) => att.error) ? "failed" : "completed";
}
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Chcete-li zprávu odeslat, přidejte příjemce",
"subject_required": "Přidejte předmět",
"body_required": "Napište zprávu nebo připojte soubor"
"body_required": "Napište zprávu nebo připojte soubor",
"attachments_uploading": "Přílohy se stále nahrávají odeslání proběhne po dokončení",
"attachment_upload_failed": "Neodesláno - přílohu se nepodařilo nahrát. Odeberte ji a zkuste to znovu."
},
"upload_progress": "Nahrávání {uploaded} / {total}",
"upload_cancel": "Zrušit nahrávání",
+3 -1
View File
@@ -634,7 +634,9 @@
"validation": {
"recipient_required": "Tilføj en modtager for at sende",
"subject_required": "Tilføj et emne",
"body_required": "Skriv en besked eller vedhæft en fil"
"body_required": "Skriv en besked eller vedhæft en fil",
"attachments_uploading": "Vedhæftninger uploades stadig — sendes, når de er færdige",
"attachment_upload_failed": "Ikke sendt - en vedhæftet fil kunne ikke uploades. Fjern den, og prøv igen."
},
"upload_progress": "Uploader {uploaded} / {total}",
"upload_cancel": "Annuller upload",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Empfänger hinzufügen zum Senden",
"subject_required": "Betreff hinzufügen",
"body_required": "Nachricht schreiben oder Datei anhängen"
"body_required": "Nachricht schreiben oder Datei anhängen",
"attachments_uploading": "Anhänge werden noch hochgeladen wird gesendet, sobald sie fertig sind",
"attachment_upload_failed": "Nicht gesendet - ein Anhang konnte nicht hochgeladen werden. Entfernen Sie ihn und versuchen Sie es erneut."
},
"upload_progress": "Hochladen {uploaded} / {total}",
"upload_cancel": "Hochladen abbrechen",
+3 -1
View File
@@ -635,7 +635,9 @@
"validation": {
"recipient_required": "Add a recipient to send",
"subject_required": "Add a subject",
"body_required": "Write a message or attach a file"
"body_required": "Write a message or attach a file",
"attachments_uploading": "Attachments are still uploading — sending as soon as they finish",
"attachment_upload_failed": "Not sent — an attachment failed to upload. Remove it and try again."
},
"upload_progress": "Uploading {uploaded} / {total}",
"upload_cancel": "Cancel upload",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Agregue un destinatario para enviar",
"subject_required": "Agregue un asunto",
"body_required": "Escriba un mensaje o adjunte un archivo"
"body_required": "Escriba un mensaje o adjunte un archivo",
"attachments_uploading": "Los archivos adjuntos aún se están subiendo; se enviará en cuanto terminen",
"attachment_upload_failed": "No enviado: no se pudo subir un adjunto. Elimínalo e inténtalo de nuevo."
},
"upload_progress": "Subiendo {uploaded} / {total}",
"upload_cancel": "Cancelar subida",
+3 -1
View File
@@ -635,7 +635,9 @@
"validation": {
"recipient_required": "یک گیرنده اضافه کنید",
"subject_required": "یک موضوع اضافه کنید",
"body_required": "پیامی بنویسید یا فایلی پیوست کنید"
"body_required": "پیامی بنویسید یا فایلی پیوست کنید",
"attachments_uploading": "پیوست‌ها هنوز در حال بارگذاری هستند — پس از اتمام ارسال می‌شود",
"attachment_upload_failed": "ارسال نشد - بارگذاری یک پیوست ناموفق بود. آن را حذف کنید و دوباره تلاش کنید."
},
"upload_progress": "در حال بارگذاری {uploaded} / {total}",
"upload_cancel": "لغو بارگذاری",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Ajoutez un destinataire pour envoyer",
"subject_required": "Ajoutez un objet",
"body_required": "Rédigez un message ou joignez un fichier"
"body_required": "Rédigez un message ou joignez un fichier",
"attachments_uploading": "Les pièces jointes sont en cours d'envoi — le message partira une fois terminé",
"attachment_upload_failed": "Non envoyé - le téléversement d'une pièce jointe a échoué. Supprimez-la et réessayez."
},
"upload_progress": "Envoi {uploaded} / {total}",
"upload_cancel": "Annuler l'envoi",
+3 -1
View File
@@ -635,7 +635,9 @@
"validation": {
"recipient_required": "Adj meg egy címzettet a küldéshez",
"subject_required": "Adj meg egy tárgyat",
"body_required": "Írj üzenetet vagy csatolj fájlt"
"body_required": "Írj üzenetet vagy csatolj fájlt",
"attachments_uploading": "A mellékletek még feltöltés alatt vannak küldés a feltöltés befejezése után",
"attachment_upload_failed": "Nincs elküldve - egy melléklet feltöltése nem sikerült. Távolítsa el, és próbálja újra."
},
"upload_progress": "Feltöltés {uploaded} / {total}",
"upload_cancel": "Feltöltés megszakítása",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Aggiungi un destinatario per inviare",
"subject_required": "Aggiungi un oggetto",
"body_required": "Scrivi un messaggio o allega un file"
"body_required": "Scrivi un messaggio o allega un file",
"attachments_uploading": "Gli allegati sono ancora in fase di caricamento: verrà inviato al termine",
"attachment_upload_failed": "Non inviato: caricamento di un allegato non riuscito. Rimuovilo e riprova."
},
"upload_progress": "Caricamento {uploaded} / {total}",
"upload_cancel": "Annulla caricamento",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "送信するには宛先を追加してください",
"subject_required": "件名を追加してください",
"body_required": "メッセージを入力するかファイルを添付してください"
"body_required": "メッセージを入力するかファイルを添付してください",
"attachments_uploading": "添付ファイルをアップロード中です。完了次第送信します",
"attachment_upload_failed": "送信されませんでした。添付ファイルのアップロードに失敗しました。削除してもう一度お試しください。"
},
"upload_progress": "アップロード中 {uploaded} / {total}",
"upload_cancel": "アップロードをキャンセル",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "보내려면 받는 사람을 추가해 주세요",
"subject_required": "제목을 입력해 주세요",
"body_required": "메시지를 작성하거나 파일을 첨부해 주세요"
"body_required": "메시지를 작성하거나 파일을 첨부해 주세요",
"attachments_uploading": "첨부 파일을 업로드하는 중입니다. 완료되면 자동으로 전송됩니다",
"attachment_upload_failed": "전송되지 않았습니다. 첨부 파일 업로드에 실패했습니다. 해당 파일을 제거하고 다시 시도하세요."
},
"upload_progress": "{uploaded} / {total} 업로드 중",
"upload_cancel": "업로드 취소",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Norādiet vismaz vienu saņēmēju",
"subject_required": "Norādiet tematu",
"body_required": "Ierakstiet ziņojumu vai pievienojiet failu"
"body_required": "Ierakstiet ziņojumu vai pievienojiet failu",
"attachments_uploading": "Pielikumi joprojām tiek augšupielādēti — nosūtīs, tiklīdz tas pabeigts",
"attachment_upload_failed": "Nav nosūtīts - pielikumu neizdevās augšupielādēt. Noņemiet to un mēģiniet vēlreiz."
},
"upload_progress": "Augšupielāde {uploaded} / {total}",
"upload_cancel": "Atcelt augšupielādi",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Voeg een ontvanger toe om te verzenden",
"subject_required": "Voeg een onderwerp toe",
"body_required": "Schrijf een bericht of voeg een bestand toe"
"body_required": "Schrijf een bericht of voeg een bestand toe",
"attachments_uploading": "Bijlagen worden nog geüpload — wordt verzonden zodra dit klaar is",
"attachment_upload_failed": "Niet verzonden - een bijlage kon niet worden geüpload. Verwijder deze en probeer het opnieuw."
},
"upload_progress": "Uploaden {uploaded} / {total}",
"upload_cancel": "Upload annuleren",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Dodaj odbiorcę, aby wysłać",
"subject_required": "Dodaj temat",
"body_required": "Napisz wiadomość lub załącz plik"
"body_required": "Napisz wiadomość lub załącz plik",
"attachments_uploading": "Załączniki wciąż się przesyłają — wiadomość zostanie wysłana po zakończeniu",
"attachment_upload_failed": "Nie wysłano - nie udało się przesłać załącznika. Usuń go i spróbuj ponownie."
},
"upload_progress": "Przesyłanie {uploaded} / {total}",
"upload_cancel": "Anuluj przesyłanie",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Adicione um destinatário para enviar",
"subject_required": "Adicione um assunto",
"body_required": "Escreva uma mensagem ou anexe um arquivo"
"body_required": "Escreva uma mensagem ou anexe um arquivo",
"attachments_uploading": "Os anexos ainda estão sendo enviados — será enviado assim que terminar",
"attachment_upload_failed": "Não enviado - falha ao carregar um anexo. Remova-o e tente novamente."
},
"upload_progress": "Enviando {uploaded} / {total}",
"upload_cancel": "Cancelar envio",
+3 -1
View File
@@ -635,7 +635,9 @@
"validation": {
"recipient_required": "Adăugați un destinatar căruia să îi trimiteți mesajul",
"subject_required": "Adăugați un subiect",
"body_required": "Scrieți un mesaj sau atașați un fișier"
"body_required": "Scrieți un mesaj sau atașați un fișier",
"attachments_uploading": "Atașamentele încă se încarcă — se va trimite imediat ce se termină",
"attachment_upload_failed": "Netrimis - încărcarea unui atașament a eșuat. Eliminați-l și încercați din nou."
},
"upload_progress": "Încărcare {uploaded} / {total}",
"upload_cancel": "Anulează încărcarea",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Укажите получателя для отправки",
"subject_required": "Укажите тему",
"body_required": "Напишите сообщение или прикрепите файл"
"body_required": "Напишите сообщение или прикрепите файл",
"attachments_uploading": "Вложения ещё загружаются — отправка начнётся, как только загрузка завершится",
"attachment_upload_failed": "Не отправлено — не удалось загрузить вложение. Удалите его и попробуйте снова."
},
"upload_progress": "Загрузка {uploaded} / {total}",
"upload_cancel": "Отменить загрузку",
+3 -1
View File
@@ -635,7 +635,9 @@
"validation": {
"recipient_required": "Göndermek için bir alıcı ekleyin",
"subject_required": "Bir konu ekleyin",
"body_required": "Bir ileti yazın veya dosya ekleyin"
"body_required": "Bir ileti yazın veya dosya ekleyin",
"attachments_uploading": "Ekler hâlâ yükleniyor — yükleme bitince gönderilecek",
"attachment_upload_failed": "Gönderilmedi - bir ek yüklenemedi. Eki kaldırıp tekrar deneyin."
},
"upload_progress": "{uploaded} / {total} yükleniyor",
"upload_cancel": "Yüklemeyi iptal et",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "Додайте одержувача для надсилання",
"subject_required": "Додайте тему",
"body_required": "Напишіть повідомлення або прикріпіть файл"
"body_required": "Напишіть повідомлення або прикріпіть файл",
"attachments_uploading": "Вкладення ще завантажуються — надсилання почнеться після завершення",
"attachment_upload_failed": "Не надіслано — не вдалося завантажити вкладення. Видаліть його та спробуйте ще раз."
},
"upload_progress": "Завантаження {uploaded} / {total}",
"upload_cancel": "Скасувати завантаження",
+3 -1
View File
@@ -632,7 +632,9 @@
"validation": {
"recipient_required": "请添加收件人",
"subject_required": "请添加主题",
"body_required": "请输入邮件内容或添加附件"
"body_required": "请输入邮件内容或添加附件",
"attachments_uploading": "附件仍在上传中,上传完成后将自动发送",
"attachment_upload_failed": "未发送 - 附件上传失败。请移除该附件后重试。"
},
"upload_progress": "正在上传 {uploaded} / {total}",
"upload_cancel": "取消上传",