From 17ca32c537dc9ffa84984bd9c1c5f7fc4475f601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 21 Feb 2026 16:14:55 +0100 Subject: [PATCH] Support large Outlook attachments via upload sessions when sending drafts (#814) * Add Outlook large attachment upload sessions for send draft * UI thread executino of draft busy state. * Limit outlook attachment limit to max allowed per attachment. --- .../Synchronizers/OutlookSynchronizer.cs | 118 +++++++++++++++--- Wino.Mail.ViewModels/ComposePageViewModel.cs | 5 +- 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 7a5c73d4..1f206c59 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -70,6 +70,9 @@ public class OutlookSynchronizer : WinoSynchronizer 20; public override uint InitialMessageDownloadCountPerFolder => 1000; private const uint MaximumAllowedBatchRequestSize = 20; + private const int SimpleAttachmentUploadLimitBytes = 3 * 1024 * 1024; + private const int MaximumUploadSessionAttachmentSizeBytes = 150 * 1024 * 1024; + private const int LargeAttachmentUploadChunkSizeBytes = 320 * 1024; private const string INBOX_NAME = "inbox"; private const string SENT_NAME = "sentitems"; @@ -1487,35 +1490,101 @@ public class OutlookSynchronizer : WinoSynchronizer patch draft -> send. - // These execute serially via batch DependsOn (see ConfigureSerialExecution). - var attachmentBundles = CreateAttachmentUploadBundles(mimeMessage, mailCopyId); - var patchDraftRequest = _graphClient.Me.Messages[mailCopyId].ToPatchRequestInformation(outlookMessage); var patchDraftBundle = new HttpRequestBundle(patchDraftRequest, request); var sendRequest = PreparePostRequestInformation(_graphClient.Me.Messages[mailCopyId].Send.ToPostRequestInformation()); var sendBundle = new HttpRequestBundle(sendRequest, request); - return [.. attachmentBundles, patchDraftBundle, sendBundle]; + // Attachment uploads are handled outside batching because large attachments + // require upload sessions whose URLs are generated dynamically. + return [patchDraftBundle, sendBundle]; } - /// - /// Extracts attachments from the MIME message and creates individual - /// Graph API upload requests using the SDK's FileAttachment type. - /// - private List> CreateAttachmentUploadBundles(MimeMessage mime, string mailCopyId) + private async Task UploadDraftAttachmentsAsync(SendDraftRequest sendDraftRequest, CancellationToken cancellationToken) { - var attachments = mime.ExtractAttachments(); - var bundles = new List>(attachments.Count); + var mailCopyId = sendDraftRequest.Request.MailItem.Id; + var attachments = sendDraftRequest.Request.Mime.ExtractAttachments(); + + if (!attachments.Any()) + { + return; + } foreach (var attachment in attachments) { - var uploadRequest = _graphClient.Me.Messages[mailCopyId].Attachments.ToPostRequestInformation(attachment); - bundles.Add(new HttpRequestBundle(uploadRequest, null)); - } + cancellationToken.ThrowIfCancellationRequested(); - return bundles; + var contentBytes = attachment.ContentBytes ?? []; + if (contentBytes.Length <= SimpleAttachmentUploadLimitBytes) + { + await _graphClient.Me.Messages[mailCopyId].Attachments.PostAsync(attachment, cancellationToken: cancellationToken).ConfigureAwait(false); + continue; + } + + if (contentBytes.Length > MaximumUploadSessionAttachmentSizeBytes) + { + var attachmentSizeMb = contentBytes.LongLength / (1024d * 1024d); + var maximumSizeMb = MaximumUploadSessionAttachmentSizeBytes / (1024d * 1024d); + + throw new InvalidOperationException( + $"Attachment '{attachment.Name}' is {attachmentSizeMb:F1} MB, which exceeds Outlook's upload limit of {maximumSizeMb:F0} MB per attachment."); + } + + var sessionBody = new Microsoft.Graph.Me.Messages.Item.Attachments.CreateUploadSession.CreateUploadSessionPostRequestBody + { + AttachmentItem = new AttachmentItem + { + AttachmentType = AttachmentType.File, + ContentType = attachment.ContentType, + Name = attachment.Name, + Size = contentBytes.LongLength + } + }; + + var uploadSession = await _graphClient.Me.Messages[mailCopyId].Attachments.CreateUploadSession.PostAsync(sessionBody, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (uploadSession?.UploadUrl == null) + { + throw new InvalidOperationException($"Failed to create upload session for attachment '{attachment.Name}'."); + } + + await UploadAttachmentInChunksAsync(uploadSession.UploadUrl, contentBytes, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task UploadAttachmentInChunksAsync(string uploadUrl, byte[] content, CancellationToken cancellationToken) + { + using var client = new HttpClient(); + + var totalSize = content.Length; + var offset = 0; + + while (offset < totalSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var chunkLength = Math.Min(LargeAttachmentUploadChunkSizeBytes, totalSize - offset); + var end = offset + chunkLength - 1; + + using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) + { + Content = new ByteArrayContent(content, offset, chunkLength) + }; + + request.Content.Headers.Add("Content-Range", $"bytes {offset}-{end}/{totalSize}"); + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Upload session returns either 202 (continue) or 201/200 (completed). + if (!response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"Attachment chunk upload failed with status {(int)response.StatusCode}: {responseContent}"); + } + + offset += chunkLength; + } } public override List> Archive(BatchArchiveRequest request) @@ -1643,6 +1712,23 @@ public class OutlookSynchronizer : WinoSynchronizer b.UIChangeRequest is SendDraftRequest)) + { + var sendDraftRequest = sendDraftBundle.UIChangeRequest as SendDraftRequest; + + try + { + await UploadDraftAttachmentsAsync(sendDraftRequest, cancellationToken).ConfigureAwait(false); + } + catch + { + sendDraftRequest?.RevertUIChanges(); + throw; + } + } + // Now batch and execute the network requests. var batchedGroups = batchedRequests.Batch((int)MaximumAllowedBatchRequestSize); diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs index da962c49..471c5f9b 100644 --- a/Wino.Mail.ViewModels/ComposePageViewModel.cs +++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs @@ -317,7 +317,10 @@ public partial class ComposePageViewModel : MailBaseViewModel, CurrentMailDraftItem.MailCopy.AssignedAccount.Preferences, base64EncodedMessage); - IsDraftBusy = true; + await ExecuteUIThread(() => + { + IsDraftBusy = true; + }); await _worker.ExecuteAsync(draftSendPreparationRequest); }