Files
duplicati/proprietary/Office365/RestoreProvider/RestoreProvider.User.cs
T
Kenneth Skovhede a4b33e5d16 Implemented Restore feature and refactored a bit.
Added a free tier for the Office 365 module.
2026-01-20 10:36:37 +01:00

571 lines
25 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using MimeKit;
namespace Duplicati.Proprietary.Office365;
partial class RestoreProvider
{
internal EmailApiImpl EmailApi => new EmailApiImpl(_apiHelper);
internal class EmailApiImpl(APIHelper provider)
{
/// <summary>
/// Limit for ensuring we stay under the 4MB limit for simple email restore.
/// </summary>
private const long MAX_SIZE_FOR_SIMPLE_EMAIL_RESTORE = (long)((4 * 1024 * 1024) * (1 - 0.33)) - 1024; // 4MB - 33% base64 overhead - 1KB margin
public async Task<string> RestoreEmailToFolderAsync(
string userId,
string targetFolderId,
Stream contentStream,
Stream metadataStream,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = System.Uri.EscapeDataString(userId);
long length = 0;
try { length = contentStream.Length; } catch { }
if (length > MAX_SIZE_FOR_SIMPLE_EMAIL_RESTORE)
return await RestoreLargeEmailAsync(userId, targetFolderId, contentStream, metadataStream, ct);
// 1) Create draft message via MIME (always goes to Drafts by default)
// POST /users/{id}/messages (MIME = base64 in text/plain body)
var createUrl = $"{baseUrl}/v1.0/users/{user}/messages";
using var base64TempFile = new Library.Utility.TempFile();
// Produce base64(MIME) as a stream without holding it in memory.
// We write once to disk so retries can reopen a fresh stream.
if (contentStream.CanSeek) contentStream.Position = 0;
await using (var outFs = new FileStream(base64TempFile, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read))
await using (var crypto = new System.Security.Cryptography.CryptoStream(
outFs,
new System.Security.Cryptography.ToBase64Transform(),
System.Security.Cryptography.CryptoStreamMode.Write))
{
await contentStream.CopyToAsync(crypto, ct).ConfigureAwait(false);
crypto.FlushFinalBlock();
await outFs.FlushAsync(ct).ConfigureAwait(false);
}
async Task<HttpRequestMessage> createRequestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, createUrl);
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Content = new StreamContent(new FileStream(base64TempFile, FileMode.Open, FileAccess.Read, FileShare.Read));
req.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
return req;
}
using var createResp = await provider.SendWithRetryShortAsync(
createRequestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(createResp, ct).ConfigureAwait(false);
var createdId = await ReadIdAsync(createResp, ct).ConfigureAwait(false);
// 2) Move to target folder (move returns a NEW message id)
// POST /users/{id}/messages/{id}/move
var moveUrl = $"{baseUrl}/v1.0/users/{user}/messages/{Uri.EscapeDataString(createdId)}/move";
async Task<HttpRequestMessage> moveRequestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, moveUrl);
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
var moveBody = JsonSerializer.SerializeToUtf8Bytes(new GraphMoveRequest { DestinationId = targetFolderId });
req.Content = new ByteArrayContent(moveBody);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return req;
}
using var moveResp = await provider.SendWithRetryShortAsync(
moveRequestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(moveResp, ct).ConfigureAwait(false);
var movedId = await ReadIdAsync(moveResp, ct).ConfigureAwait(false);
if (metadataStream != null)
{
// 3) Patch metadata on moved message
await PatchRestoredEmailMetadataAsync(
userId,
movedId,
metadataStream,
ct).ConfigureAwait(false);
}
return movedId;
}
private async Task<string> RestoreLargeEmailAsync(
string userId,
string targetFolderId,
Stream contentStream,
Stream metadataStream,
CancellationToken ct)
{
// 1. Parse MIME
if (contentStream.CanSeek) contentStream.Position = 0;
var message = await MimeMessage.LoadAsync(contentStream, ct);
// 2. Create Draft Message (without attachments)
var draft = new GraphMessage
{
Subject = message.Subject,
Body = new GraphBody
{
ContentType = message.HtmlBody != null ? "html" : "text",
Content = message.HtmlBody ?? message.TextBody ?? ""
},
From = ConvertToGraphRecipient(message.From),
Sender = ConvertToGraphRecipient(message.Sender),
ToRecipients = ConvertToGraphRecipients(message.To),
CcRecipients = ConvertToGraphRecipients(message.Cc),
BccRecipients = ConvertToGraphRecipients(message.Bcc),
HasAttachments = message.Attachments.Any()
};
var createdDraft = await CreateMessageAsync(userId, draft, ct);
// 3. Upload Attachments
foreach (var attachment in message.Attachments)
{
await UploadAttachmentAsync(userId, createdDraft.Id, attachment, ct);
}
// 4. Move to target folder
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var userEncoded = Uri.EscapeDataString(userId);
var moveUrl = $"{baseUrl}/v1.0/users/{userEncoded}/messages/{Uri.EscapeDataString(createdDraft.Id)}/move";
async Task<HttpRequestMessage> moveRequestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, moveUrl);
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
var moveBody = JsonSerializer.SerializeToUtf8Bytes(new GraphMoveRequest { DestinationId = targetFolderId });
req.Content = new ByteArrayContent(moveBody);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return req;
}
using var moveResp = await provider.SendWithRetryShortAsync(
moveRequestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(moveResp, ct).ConfigureAwait(false);
var movedId = await ReadIdAsync(moveResp, ct).ConfigureAwait(false);
// 5. Patch metadata
if (metadataStream != null)
{
await PatchRestoredEmailMetadataAsync(
userId,
movedId,
metadataStream,
ct).ConfigureAwait(false);
}
return movedId;
}
private async Task<GraphCreatedMessage> CreateMessageAsync(string userId, GraphMessage message, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
var url = $"{baseUrl}/v1.0/users/{user}/messages";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Content = JsonContent.Create(message);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
using var s = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<GraphCreatedMessage>(s, cancellationToken: ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to create draft message");
}
private async Task UploadAttachmentAsync(string userId, string messageId, MimeEntity attachment, CancellationToken ct)
{
if (attachment is MessagePart msgPart)
{
// Handle attached message as .eml file
using var ms = new MemoryStream();
await msgPart.Message.WriteToAsync(ms, ct);
ms.Position = 0;
// Create a fake MimePart for upload logic
var fakePart = new MimePart("message", "rfc822")
{
Content = new MimeContent(ms),
FileName = (msgPart.Message.Subject ?? "attached") + ".eml"
};
await UploadAttachmentAsync(userId, messageId, fakePart, ct);
return;
}
if (attachment is not MimePart part) return;
// Check size
long size = 0;
using (var measure = new MemoryStream())
{
await part.Content.DecodeToAsync(measure, ct);
size = measure.Length;
}
if (size < 3 * 1024 * 1024)
{
// Small attachment: POST /users/{id}/messages/{id}/attachments
await UploadSmallAttachmentAsync(userId, messageId, part, size, ct);
}
else
{
// Large attachment: createUploadSession
await UploadLargeAttachmentAsync(userId, messageId, part, size, ct);
}
}
private async Task UploadSmallAttachmentAsync(string userId, string messageId, MimePart part, long size, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
var msg = Uri.EscapeDataString(messageId);
var url = $"{baseUrl}/v1.0/users/{user}/messages/{msg}/attachments";
using var ms = new MemoryStream();
await part.Content.DecodeToAsync(ms, ct);
var bytes = ms.ToArray();
var attach = new GraphAttachment
{
ODataType = "#microsoft.graph.fileAttachment",
Name = part.FileName ?? "attachment",
ContentType = part.ContentType.MimeType,
Size = (int)size,
IsInline = !string.IsNullOrEmpty(part.ContentId),
ContentId = part.ContentId,
ContentBytes = Convert.ToBase64String(bytes)
};
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Content = JsonContent.Create(attach);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
}
private async Task UploadLargeAttachmentAsync(string userId, string messageId, MimePart part, long size, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
var msg = Uri.EscapeDataString(messageId);
var url = $"{baseUrl}/v1.0/users/{user}/messages/{msg}/attachments/createUploadSession";
var attachmentItem = new GraphAttachmentItem
{
AttachmentType = "file",
Name = part.FileName ?? "attachment",
Size = size
};
var body = new { AttachmentItem = attachmentItem };
async Task<HttpRequestMessage> sessionRequestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Content = JsonContent.Create(body);
return req;
}
using var sessionResp = await provider.SendWithRetryShortAsync(sessionRequestFactory, ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(sessionResp, ct).ConfigureAwait(false);
using var sessionStream = await sessionResp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var session = await JsonSerializer.DeserializeAsync<GraphUploadSession>(sessionStream, cancellationToken: ct).ConfigureAwait(false);
if (session?.UploadUrl == null) throw new InvalidOperationException("Failed to create upload session");
// Upload chunks
using var contentStream = new MemoryStream();
await part.Content.DecodeToAsync(contentStream, ct);
contentStream.Position = 0;
await provider.UploadFileToSessionAsync(session.UploadUrl, contentStream, ct);
}
private GraphRecipient? ConvertToGraphRecipient(InternetAddressList? addresses)
{
var addr = addresses?.Mailboxes.FirstOrDefault();
if (addr == null) return null;
return new GraphRecipient
{
EmailAddress = new GraphEmailAddress { Name = addr.Name, Address = addr.Address }
};
}
private GraphRecipient? ConvertToGraphRecipient(InternetAddress? address)
{
if (address is MailboxAddress mailbox)
{
return new GraphRecipient
{
EmailAddress = new GraphEmailAddress { Name = mailbox.Name, Address = mailbox.Address }
};
}
return null;
}
private List<GraphRecipient>? ConvertToGraphRecipients(InternetAddressList? addresses)
{
if (addresses == null || addresses.Count == 0) return null;
return addresses.Mailboxes.Select(addr => new GraphRecipient
{
EmailAddress = new GraphEmailAddress { Name = addr.Name, Address = addr.Address }
}).ToList();
}
private static async Task<string> ReadIdAsync(HttpResponseMessage resp, CancellationToken ct)
{
await using var s = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var created = await JsonSerializer.DeserializeAsync<GraphCreatedMessage>(s, cancellationToken: ct)
.ConfigureAwait(false);
if (created?.Id is null || created.Id.Length == 0)
throw new InvalidOperationException("Graph did not return an id in the response body.");
return created.Id;
}
public async Task PatchRestoredEmailMetadataAsync(
string userIdOrUpn,
string restoredMessageId,
Stream metadataStream, // seekable
CancellationToken cancellationToken)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userIdOrUpn);
var msg = Uri.EscapeDataString(restoredMessageId);
var url = $"{baseUrl}/v1.0/users/{user}/messages/{msg}";
var jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
// Deserialize from the seekable stream (no buffering)
if (metadataStream.CanSeek) metadataStream.Position = 0;
var metadata = await JsonSerializer.DeserializeAsync<GraphEmailMessageMetadata>(
metadataStream, jsonOptions, cancellationToken).ConfigureAwait(false)
?? new GraphEmailMessageMetadata();
var patch = new GraphEmailMessagePatch
{
IsRead = metadata.IsRead,
Importance = metadata.Importance,
Categories = metadata.Categories,
Flag = metadata.Flag
};
// Write PATCH payload to a temp file so retries can reopen a fresh stream
var tempPath = Path.Combine(Path.GetTempPath(), $"msgpatch_{Guid.NewGuid():N}.json");
try
{
await using (var fs = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read))
{
await JsonSerializer.SerializeAsync(fs, patch, jsonOptions, cancellationToken).ConfigureAwait(false);
await fs.FlushAsync(cancellationToken).ConfigureAwait(false);
}
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
{
var req = new HttpRequestMessage(HttpMethod.Patch, new Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
// New stream per attempt (safe for retries; avoids in-memory buffering)
var bodyStream = new FileStream(tempPath, FileMode.Open, FileAccess.Read, FileShare.Read);
req.Content = new StreamContent(bodyStream);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return req;
}
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
cancellationToken).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
}
finally
{
try { File.Delete(tempPath); } catch { /* ignore */ }
}
}
public async Task<GraphMailFolder?> GetChildFolderAsync(
string userIdOrUpn,
string parentFolderId,
string displayName,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userIdOrUpn);
var parent = Uri.EscapeDataString(parentFolderId);
var filterName = displayName.Replace("'", "''");
var url = $"{baseUrl}/v1.0/users/{user}/mailFolders/{parent}/childFolders?$filter=displayName eq '{filterName}'&$top=1";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var result = await JsonSerializer.DeserializeAsync<GraphPage<GraphMailFolder>>(stream, cancellationToken: ct).ConfigureAwait(false);
return result?.Value?.FirstOrDefault();
}
public async Task<GraphMailFolder> CreateMailFolderAsync(
string userIdOrUpn,
string parentFolderId,
string displayName,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userIdOrUpn);
var parent = Uri.EscapeDataString(parentFolderId);
// POST /users/{id}/mailFolders/{id}/childFolders
var url = $"{baseUrl}/v1.0/users/{user}/mailFolders/{parent}/childFolders";
var body = new GraphCreateMailFolderRequest
{
DisplayName = displayName
};
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
=> new HttpRequestMessage(HttpMethod.Post, new Uri(url))
{
Headers =
{
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
},
Content = JsonContent.Create(body)
};
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
await using var respStream =
await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var created = await JsonSerializer.DeserializeAsync<GraphMailFolder>(
respStream,
cancellationToken: ct).ConfigureAwait(false);
if (created is null || string.IsNullOrWhiteSpace(created.Id))
throw new InvalidOperationException("Graph did not return the created folder id.");
return created;
}
public async Task<bool> EmailExistsInFolderByInternetMessageIdAsync(
string userIdOrUpn,
string folderIdOrWellKnownName,
string internetMessageId,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userIdOrUpn);
// Escape OData string literals (single quotes doubled)
var imid = internetMessageId.Replace("'", "''");
var folder = folderIdOrWellKnownName.Replace("'", "''");
// Constrain by both internetMessageId AND parentFolderId
var url =
$"{baseUrl}/v1.0/users/{user}/messages" +
$"?$filter=internetMessageId eq '{imid}' and parentFolderId eq '{folder}'" +
$"&$select=id" +
$"&$top=1";
using var stream = await provider.GetGraphItemAsStreamAsync(
url, "application/json", ct).ConfigureAwait(false);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct)
.ConfigureAwait(false);
return doc.RootElement.TryGetProperty("value", out var value) &&
value.ValueKind == JsonValueKind.Array &&
value.GetArrayLength() > 0;
}
public async Task UpdateMailboxSettingsAsync(string userIdOrUpn, GraphMailboxSettings settings, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userIdOrUpn);
var url = $"{baseUrl}/v1.0/users/{user}/mailboxSettings";
var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
await provider.PatchGraphItemAsync(url, content, ct);
}
public async Task CreateMessageRuleAsync(string userIdOrUpn, GraphMessageRule rule, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userIdOrUpn);
var url = $"{baseUrl}/v1.0/users/{user}/mailFolders/inbox/messageRules";
// Remove ID and read-only properties before creating
rule.Id = "";
rule.IsReadOnly = null;
rule.HasError = null;
var json = JsonSerializer.Serialize(rule, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
await provider.PostGraphItemAsync<GraphMessageRule>(url, content, ct);
}
}
}