diff --git a/proprietary/Office365/APIHelper.cs b/proprietary/Office365/APIHelper.cs
new file mode 100644
index 000000000..bc5a2ea49
--- /dev/null
+++ b/proprietary/Office365/APIHelper.cs
@@ -0,0 +1,450 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Utility;
+using Duplicati.Library.Utility.Options;
+using NetUri = System.Uri;
+
+namespace Duplicati.Proprietary.Office365;
+
+///
+/// Implements the API helper class for Microsoft Graph API access
+///
+internal class APIHelper : IDisposable
+{
+ ///
+ /// The HTTP client used for API requests
+ ///
+ private readonly HttpClient _httpClient;
+ ///
+ /// The cached Graph access token
+ ///
+ private string? _graphAccessToken;
+ ///
+ /// The authentication values
+ ///
+ private readonly AuthOptionsHelper.AuthOptions _authOptions;
+ ///
+ /// The tenant ID for the API requests
+ ///
+ private readonly string _tenantId;
+ ///
+ /// The base URL for Graph API requests
+ ///
+ private readonly string _graphBaseUrl;
+ ///
+ /// The base URL for Graph API requests
+ ///
+ public string GraphBaseUrl => _graphBaseUrl;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HTTP client to use for API requests
+ /// The authentication options
+ /// The tenant ID
+ /// The base URL for Graph API requests
+ private APIHelper(HttpClient httpClient, AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl)
+ {
+ _httpClient = httpClient;
+ _authOptions = authOptions;
+ _tenantId = tenantId;
+ _graphBaseUrl = graphBaseUrl;
+ }
+
+ ///
+ /// Creates a new instance of the class.
+ ///
+ /// The authentication options
+ /// The tenant ID
+ /// The base URL for Graph API requests
+ /// A new instance of the class
+ public static APIHelper Create(AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl)
+ {
+ var handler = new HttpClientHandler
+ {
+ UseCookies = false
+ };
+
+ var httpClient = HttpClientHelper.CreateClient(handler);
+ httpClient.Timeout = Timeout.InfiniteTimeSpan;
+ httpClient.DefaultRequestHeaders.Add("User-Agent", $"Duplicati/{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}");
+
+ return new APIHelper(httpClient, authOptions, tenantId, graphBaseUrl);
+ }
+
+ ///
+ /// Gets the authentication header for the API requests.
+ ///
+ /// Whether to refresh the access token
+ /// The cancellation token
+ /// The authentication header
+ public async Task GetAuthenticationHeaderAsync(bool refresh, CancellationToken cancellationToken)
+ {
+ var accessToken = await AcquireAccessTokenAsync(refresh, cancellationToken).ConfigureAwait(false);
+ return new AuthenticationHeaderValue("Bearer", accessToken);
+ }
+
+ ///
+ /// Acquires an access token for the API requests.
+ ///
+ /// Whether to refresh the access token
+ /// The cancellation token
+ /// The access token
+ public async Task AcquireAccessTokenAsync(bool refresh, CancellationToken cancellationToken)
+ {
+ if (!refresh && !string.IsNullOrWhiteSpace(_graphAccessToken))
+ return _graphAccessToken;
+
+ var tokenEndpoint = new NetUri($"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token");
+ using var content = new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "client_credentials",
+ ["client_id"] = _authOptions.Username!,
+ ["client_secret"] = _authOptions.Password!,
+ ["scope"] = $"{_graphBaseUrl.TrimEnd('/')}/.default"
+ });
+
+ using var client = HttpClientHelper.CreateClient();
+ using var response = await client.PostAsync(tokenEndpoint, content, cancellationToken).ConfigureAwait(false);
+ await EnsureOfficeApiSuccessAsync(response, cancellationToken).ConfigureAwait(false);
+
+ var token = await ParseResponseJson(response, cancellationToken).ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(token?.AccessToken))
+ throw new UserInformationException(Strings.MissingAccessToken, "MissingAccessTokenInResponse");
+
+ return token.AccessToken;
+ }
+
+ ///
+ /// Parses the response JSON.
+ ///
+ /// The type to deserialize to
+ /// The HTTP response message
+ /// The cancellation token
+ /// The deserialized object
+ public static async Task ParseResponseJson(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ using var reader = new StreamReader(stream);
+ var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ return JsonSerializer.Deserialize(content) ?? throw new JsonException("Deserialized object is null");
+ }
+ catch (JsonException ex)
+ {
+ throw new UserInformationException($"Failed to parse response JSON: {ex.Message}, JSON: {content}", "FailedToParseResponseJson", ex);
+ }
+ }
+
+ ///
+ /// Ensures that the response from the Office API is successful.
+ ///
+ /// The HTTP response message
+ /// The cancellation token
+ /// An awaitable task
+ public static async Task EnsureOfficeApiSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ if (response.IsSuccessStatusCode)
+ return;
+
+ string? responseBody = null;
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(1));
+
+ responseBody = response.Content == null
+ ? null
+ : await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Ignore failures when reading the response body; we'll throw with status code regardless.
+ }
+
+ var message = TryExtractOfficeApiErrorMessage(responseBody)
+ ?? responseBody
+ ?? response.ReasonPhrase
+ ?? "Request failed.";
+
+ // Ensure the status code is both in the message (human readable) and on the exception (machine readable).
+ var statusCode = response.StatusCode;
+ var detailedMessage = $"{message} (HTTP {(int)statusCode} {statusCode}) - Request URI: {response.RequestMessage?.RequestUri}";
+ if (response?.Headers?.TryGetValues("Location", out var location) == true)
+ detailedMessage += $" - Location: {location.First()}";
+ throw new HttpRequestException(detailedMessage, inner: null, statusCode);
+ }
+
+ ///
+ /// Attempts to extract the error message from the Office API response.
+ ///
+ /// The response body
+ /// The extracted error message, or null if none could be found
+ private static string? TryExtractOfficeApiErrorMessage(string? responseBody)
+ {
+ if (string.IsNullOrWhiteSpace(responseBody))
+ return null;
+
+ // Fast filter: if it doesn't look like JSON, don't attempt to parse.
+ var trimmed = responseBody.AsSpan().TrimStart();
+ if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
+ return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(responseBody);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
+ return null;
+
+ if (doc.RootElement.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object)
+ {
+ if (error.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String)
+ {
+ var message = messageElement.GetString();
+ return string.IsNullOrWhiteSpace(message) ? null : message;
+ }
+ }
+ }
+ catch (JsonException)
+ {
+ // Not JSON (or invalid JSON) - fall back to entire response body.
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets a single page of Graph API results.
+ ///
+ /// The type of items in the page
+ /// The URL to fetch the page from
+ /// A callback to determine if a request should be retried
+ /// The cancellation token
+ /// >The page of results
+ public async Task?> GetGraphPageAsync(string url, Func? shouldRetry, CancellationToken ct)
+ {
+ async Task requestFactory(CancellationToken ct)
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url));
+ req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
+ req.Headers.Accept.Clear();
+ req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ return req;
+ }
+
+ using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, shouldRetry, ct).ConfigureAwait(false);
+ await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
+
+ return await ParseResponseJson>(resp, ct).ConfigureAwait(false)
+ ?? new GraphPage();
+ }
+
+
+ ///
+ /// Gets all Graph API results from a paged endpoint as an asynchronous enumerable.
+ ///
+ /// The type of items in the page
+ /// The initial URL to fetch results from
+ /// The cancellation token
+ /// An asynchronous enumerable of all items
+ public IAsyncEnumerable GetAllGraphItemsAsync(string initialUrl, CancellationToken ct)
+ => GetAllGraphItemsAsync(initialUrl, null, ct);
+
+ ///
+ /// Gets all Graph API results from a paged endpoint as an asynchronous enumerable.
+ ///
+ /// The type of items in the page
+ /// The initial URL to fetch results from
+ /// A callback to determine if a request should be retried
+ /// The cancellation token
+ /// An asynchronous enumerable of all items
+ public async IAsyncEnumerable GetAllGraphItemsAsync(string initialUrl, Func? shouldRetry, [EnumeratorCancellation] CancellationToken ct)
+ {
+ var next = initialUrl;
+ while (!string.IsNullOrWhiteSpace(next))
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var page = await GetGraphPageAsync(next, shouldRetry, ct).ConfigureAwait(false);
+
+ // If page is null, the shouldRetry callback indicated we should treat this as empty results
+ // (e.g., 503 when OneDrive is not provisioned for a user)
+ if (page == null)
+ yield break;
+
+ foreach (var item in page.Value)
+ yield return item;
+ next = page.NextLink;
+ }
+ }
+
+ ///
+ /// Gets a single Graph API item.
+ ///
+ /// The graph item to get
+ /// The URL to fetch
+ /// The cancellation token
+ /// >The graph item
+ public async Task GetGraphItemAsync(string url, CancellationToken ct)
+ {
+ var select = GraphSelectBuilder.BuildSelect();
+
+ async Task requestFactory(CancellationToken cancellationToken)
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url));
+ req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken);
+ req.Headers.Accept.Clear();
+ req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ return req;
+ }
+
+ using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, ct).ConfigureAwait(false);
+ await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
+
+ return await ParseResponseJson(resp, ct).ConfigureAwait(false)
+ ?? throw new UserInformationException("Failed to parse Graph item response.", nameof(SourceProvider));
+ }
+
+ ///
+ /// Returns the response from the Graph API as a stream.
+ ///
+ /// The URL to fetch
+ /// The Accept header value
+ /// The cancellation token
+ /// The response stream
+ public Task GetGraphAsStreamAsync(string url, string accept, CancellationToken ct)
+ => GetGraphAsStreamAsync(url, accept, null, ct);
+
+ ///
+ /// Returns the response from the Graph API as a stream.
+ ///
+ /// The URL to fetch
+ /// The Accept header value
+ /// A callback to determine if a request should be retried
+ /// The cancellation token
+ /// The response stream
+ private async Task GetGraphAsStreamAsync(string url, string accept, Func? shouldRetry, CancellationToken ct)
+ {
+ async Task requestFactory(CancellationToken ct)
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url));
+ req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
+ req.Headers.Accept.Clear();
+ req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
+ return req;
+ }
+
+ using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, shouldRetry, ct).ConfigureAwait(false);
+ if (resp.StatusCode == HttpStatusCode.NotFound)
+ return Stream.Null;
+ await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
+
+ // Return a stream the caller can own safely after HttpResponseMessage disposal.
+ var ms = new MemoryStream();
+ await resp.Content.CopyToAsync(ms, ct).ConfigureAwait(false);
+ ms.Position = 0;
+ return ms;
+ }
+
+ ///
+ /// Gets the HTTP client, acquiring an access token if needed.
+ ///
+ /// The cancellation token
+ /// The HTTP client
+ private async Task GetHttpClientAsync(CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(_graphAccessToken))
+ _graphAccessToken = await AcquireAccessTokenAsync(false, ct).ConfigureAwait(false);
+
+ return _httpClient;
+ }
+
+ ///
+ /// Sends an HTTP request with retry logic for transient failures.
+ ///
+ /// The factory to create the HTTP request
+ /// The HTTP completion option
+ /// A callback to determine if a request should be retried
+ /// The cancellation token
+ /// The maximum number of retries
+ /// >The HTTP response message
+ public async Task SendWithRetryAsync(
+ Func> requestFactory,
+ HttpCompletionOption completionOption,
+ Func? shouldRetry,
+ CancellationToken ct,
+ int maxRetries = 4)
+ {
+ var attempt = 0;
+ var isReAuthAttempt = false;
+ var client = await GetHttpClientAsync(ct).ConfigureAwait(false);
+
+ while (true)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ using var request = await requestFactory(ct).ConfigureAwait(false);
+ var resp = await client.SendAsync(request, completionOption, ct).ConfigureAwait(false);
+
+ if (resp.IsSuccessStatusCode)
+ return resp;
+
+ if (shouldRetry?.Invoke(resp) == false)
+ return resp;
+
+ // Retryable status codes:
+ // 429: TooManyRequests
+ // 503: ServiceUnavailable
+ // 502: BadGateway
+ // 504: GatewayTimeout
+
+ switch (resp.StatusCode)
+ {
+ case HttpStatusCode.TooManyRequests:
+ case HttpStatusCode.ServiceUnavailable:
+ case HttpStatusCode.BadGateway:
+ case HttpStatusCode.GatewayTimeout:
+ break;
+ case HttpStatusCode.Unauthorized:
+ _graphAccessToken = null; // force re-auth
+ if (isReAuthAttempt)
+ return resp; // already tried re-auth
+ isReAuthAttempt = true;
+ break;
+ default:
+ return resp;
+ }
+
+ attempt++;
+ if (attempt > maxRetries)
+ return resp; // let EnsureOfficeApiSuccessAsync throw with details
+
+ // Respect Retry-After if present, else exponential backoff.
+ TimeSpan delay;
+ if (resp.Headers.RetryAfter?.Delta is TimeSpan ra)
+ {
+ delay = ra;
+ }
+ else
+ {
+ var baseSeconds = Math.Min(60, Math.Pow(2, attempt)); // cap at 60s
+ var jitterMs = Random.Shared.Next(0, 500);
+ delay = TimeSpan.FromSeconds(baseSeconds) + TimeSpan.FromMilliseconds(jitterMs);
+ }
+
+ resp.Dispose();
+ await Task.Delay(delay, ct).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ _graphAccessToken = null;
+ }
+}
diff --git a/proprietary/Office365/DTOs.cs b/proprietary/Office365/DTOs.cs
new file mode 100644
index 000000000..634810cbc
--- /dev/null
+++ b/proprietary/Office365/DTOs.cs
@@ -0,0 +1,1019 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Duplicati.Proprietary.Office365;
+
+internal sealed class GraphUser
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("userPrincipalName")]
+ public string? UserPrincipalName { get; set; }
+
+ [JsonPropertyName("accountEnabled")]
+ public bool? AccountEnabled { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+}
+
+internal sealed class GraphSite
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("webUrl")]
+ public string? WebUrl { get; set; }
+
+ [JsonPropertyName("siteCollection")]
+ public GraphSiteCollection? SiteCollection { get; set; }
+}
+
+internal sealed class GraphSiteCollection
+{
+ [JsonPropertyName("hostname")]
+ public string? Hostname { get; set; }
+
+ [JsonPropertyName("personalSite")]
+ public bool? PersonalSite { get; set; }
+}
+
+internal sealed class OfficeTokenResponse
+{
+ [JsonPropertyName("token_type")]
+ public string TokenType { get; set; } = string.Empty;
+ [JsonPropertyName("access_token")]
+ public string AccessToken { get; set; } = string.Empty;
+ [JsonPropertyName("expires_in")]
+ public int ExpiresIn { get; set; }
+ [JsonPropertyName("expires_on")]
+ public string ExpiresOn { get; set; } = string.Empty;
+}
+
+internal sealed class GraphPage
+{
+ [JsonPropertyName("value")]
+ public List Value { get; set; } = [];
+
+ [JsonPropertyName("@odata.nextLink")]
+ public string? NextLink { get; set; }
+}
+
+internal sealed class GraphMailFolder
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("parentFolderId")]
+ public string? ParentFolderId { get; set; }
+
+ [JsonPropertyName("childFolderCount")]
+ public int? ChildFolderCount { get; set; }
+
+ [JsonPropertyName("totalItemCount")]
+ public int? TotalItemCount { get; set; }
+
+ [JsonPropertyName("unreadItemCount")]
+ public int? UnreadItemCount { get; set; }
+
+ // Indicates whether the folder is hidden (not returned by default when listing)
+ [JsonPropertyName("isHidden")]
+ public bool? IsHidden { get; set; }
+}
+
+internal sealed class GraphMessage
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("subject")]
+ public string? Subject { get; set; }
+
+ [JsonPropertyName("receivedDateTime")]
+ public DateTimeOffset? ReceivedDateTime { get; set; }
+
+ [JsonPropertyName("sentDateTime")]
+ public DateTimeOffset? SentDateTime { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ [JsonPropertyName("internetMessageId")]
+ public string? InternetMessageId { get; set; }
+
+ [JsonPropertyName("from")]
+ public GraphRecipient? From { get; set; }
+
+ [JsonPropertyName("toRecipients")]
+ public List? ToRecipients { get; set; }
+
+ [JsonPropertyName("hasAttachments")]
+ public bool? HasAttachments { get; set; }
+}
+
+internal sealed class GraphRecipient
+{
+ [JsonPropertyName("emailAddress")]
+ public GraphEmailAddress? EmailAddress { get; set; }
+}
+
+internal sealed class GraphEmailAddress
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("address")]
+ public string? Address { get; set; }
+}
+
+internal sealed class GraphContact
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("givenName")]
+ public string? GivenName { get; set; }
+
+ [JsonPropertyName("surname")]
+ public string? Surname { get; set; }
+
+ [JsonPropertyName("emailAddresses")]
+ public List? EmailAddresses { get; set; }
+
+ [JsonPropertyName("mobilePhone")]
+ public string? MobilePhone { get; set; }
+
+ [JsonPropertyName("businessPhones")]
+ public List? BusinessPhones { get; set; }
+
+ [JsonPropertyName("companyName")]
+ public string? CompanyName { get; set; }
+
+ [JsonPropertyName("jobTitle")]
+ public string? JobTitle { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+}
+
+internal sealed class GraphContactEmail
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("address")]
+ public string? Address { get; set; }
+}
+
+internal sealed class GraphTodoTaskList
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("isOwner")]
+ public bool? IsOwner { get; set; }
+
+ [JsonPropertyName("isShared")]
+ public bool? IsShared { get; set; }
+
+ // none, defaultList, flaggedEmails, unknownFutureValue
+ [JsonPropertyName("wellknownListName")]
+ public string? WellknownListName { get; set; }
+}
+
+internal sealed class GraphNotebook
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ [JsonPropertyName("isDefault")]
+ public bool? IsDefault { get; set; }
+}
+
+internal sealed class GraphOnenoteSection
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+}
+
+internal sealed class GraphOnenoteSectionGroup
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+}
+
+internal sealed class GraphOnenotePage
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ [JsonPropertyName("contentUrl")]
+ public string? ContentUrl { get; set; }
+}
+
+internal sealed class GraphTodoTask
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonPropertyName("importance")]
+ public string? Importance { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ [JsonPropertyName("completedDateTime")]
+ public object? CompletedDateTime { get; set; }
+
+ [JsonPropertyName("dueDateTime")]
+ public object? DueDateTime { get; set; }
+
+ [JsonPropertyName("body")]
+ public object? Body { get; set; }
+}
+
+internal sealed class GraphTodoChecklistItem
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("isChecked")]
+ public bool? IsChecked { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+}
+
+internal sealed class GraphTodoLinkedResource
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("webUrl")]
+ public string? WebUrl { get; set; }
+
+ [JsonPropertyName("applicationName")]
+ public string? ApplicationName { get; set; }
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("externalId")]
+ public string? ExternalId { get; set; }
+}
+
+internal sealed class GraphDriveItem
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("size")]
+ public long? Size { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ [JsonPropertyName("eTag")]
+ public string? ETag { get; set; }
+
+ [JsonPropertyName("cTag")]
+ public string? CTag { get; set; }
+
+ [JsonPropertyName("parentReference")]
+ public GraphDriveItemParentReference? ParentReference { get; set; }
+
+ // Present in delta results when deleted
+ [JsonPropertyName("deleted")]
+ public JsonElement? Deleted { get; set; }
+
+ // Facets (presence indicates item type)
+ [JsonPropertyName("folder")]
+ public GraphDriveFolderFacet? Folder { get; set; }
+
+ [JsonPropertyName("file")]
+ public GraphDriveFileFacet? File { get; set; }
+
+ [JsonPropertyName("fileSystemInfo")]
+ public GraphDriveFileSystemInfo? FileSystemInfo { get; set; }
+
+ // Often useful when downloading
+ [JsonPropertyName("@microsoft.graph.downloadUrl")]
+ public string? DownloadUrl { get; set; }
+}
+
+internal sealed class GraphDriveFolderFacet
+{
+ [JsonPropertyName("childCount")]
+ public int? ChildCount { get; set; }
+}
+
+internal sealed class GraphDriveFileFacet
+{
+ [JsonPropertyName("mimeType")]
+ public string? MimeType { get; set; }
+
+ [JsonPropertyName("hashes")]
+ public GraphDriveHashes? Hashes { get; set; }
+}
+
+internal sealed class GraphDriveHashes
+{
+ [JsonPropertyName("quickXorHash")]
+ public string? QuickXorHash { get; set; }
+
+ [JsonPropertyName("sha1Hash")]
+ public string? Sha1Hash { get; set; }
+
+ [JsonPropertyName("sha256Hash")]
+ public string? Sha256Hash { get; set; }
+}
+
+internal sealed class GraphDriveFileSystemInfo
+{
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+}
+
+internal sealed class GraphDriveItemParentReference
+{
+ [JsonPropertyName("driveId")]
+ public string? DriveId { get; set; }
+
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("path")]
+ public string? Path { get; set; }
+}
+
+internal sealed class GraphDeltaPage
+{
+ [JsonPropertyName("value")]
+ public List Value { get; set; } = [];
+
+ [JsonPropertyName("@odata.nextLink")]
+ public string? NextLink { get; set; }
+
+ [JsonPropertyName("@odata.deltaLink")]
+ public string? DeltaLink { get; set; }
+}
+
+internal sealed record GraphDeltaResult(List Items, string DeltaLink);
+
+internal sealed class GraphDrive
+{
+ // baseItem
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("webUrl")]
+ public string? WebUrl { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ [JsonPropertyName("createdBy")]
+ public object? CreatedBy { get; set; }
+
+ [JsonPropertyName("lastModifiedBy")]
+ public object? LastModifiedBy { get; set; }
+
+ // drive
+ [JsonPropertyName("driveType")]
+ public string? DriveType { get; set; }
+
+ [JsonPropertyName("owner")]
+ public object? Owner { get; set; }
+
+ [JsonPropertyName("quota")]
+ public object? Quota { get; set; }
+
+ [JsonPropertyName("sharepointIds")]
+ public object? SharePointIds { get; set; }
+
+ [JsonPropertyName("system")]
+ public object? System { get; set; }
+}
+
+internal sealed class GraphCalendarGroup
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("classId")]
+ public string? ClassId { get; set; }
+
+ [JsonPropertyName("changeKey")]
+ public string? ChangeKey { get; set; }
+}
+
+internal sealed class GraphCalendar
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("changeKey")]
+ public string? ChangeKey { get; set; }
+
+ // Graph: calendarColor enum (kept as string for forward compatibility)
+ [JsonPropertyName("color")]
+ public string? Color { get; set; }
+
+ // Graph: hexColor string (e.g. "#FF0000")
+ [JsonPropertyName("hexColor")]
+ public string? HexColor { get; set; }
+
+ [JsonPropertyName("isDefaultCalendar")]
+ public bool? IsDefaultCalendar { get; set; }
+
+ [JsonPropertyName("isRemovable")]
+ public bool? IsRemovable { get; set; }
+
+ [JsonPropertyName("canEdit")]
+ public bool? CanEdit { get; set; }
+
+ [JsonPropertyName("canShare")]
+ public bool? CanShare { get; set; }
+
+ [JsonPropertyName("canViewPrivateItems")]
+ public bool? CanViewPrivateItems { get; set; }
+
+ [JsonPropertyName("isTallyingResponses")]
+ public bool? IsTallyingResponses { get; set; }
+
+ // Values like: unknown, skypeForBusiness, skypeForBusinessForTeams, teamsForBusiness
+ [JsonPropertyName("defaultOnlineMeetingProvider")]
+ public string? DefaultOnlineMeetingProvider { get; set; }
+
+ [JsonPropertyName("allowedOnlineMeetingProviders")]
+ public List? AllowedOnlineMeetingProviders { get; set; }
+
+ [JsonPropertyName("owner")]
+ public GraphEmailAddress? Owner { get; set; }
+}
+
+internal sealed class GraphEvent
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("subject")]
+ public string? Subject { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ // Graph uses dateTimeTimeZone objects for start/end
+ [JsonPropertyName("start")]
+ public object? Start { get; set; }
+
+ [JsonPropertyName("end")]
+ public object? End { get; set; }
+
+ [JsonPropertyName("isAllDay")]
+ public bool? IsAllDay { get; set; }
+
+ [JsonPropertyName("isCancelled")]
+ public bool? IsCancelled { get; set; }
+
+ [JsonPropertyName("location")]
+ public object? Location { get; set; }
+
+ [JsonPropertyName("organizer")]
+ public object? Organizer { get; set; }
+
+ [JsonPropertyName("attendees")]
+ public object? Attendees { get; set; }
+
+ [JsonPropertyName("recurrence")]
+ public object? Recurrence { get; set; }
+
+ [JsonPropertyName("seriesMasterId")]
+ public string? SeriesMasterId { get; set; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; } // singleInstance, occurrence, exception, seriesMaster
+}
+
+internal sealed class GraphPlannerTask
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("planId")]
+ public string? PlanId { get; set; }
+
+ [JsonPropertyName("bucketId")]
+ public string? BucketId { get; set; }
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("startDateTime")]
+ public DateTimeOffset? StartDateTime { get; set; }
+
+ [JsonPropertyName("dueDateTime")]
+ public DateTimeOffset? DueDateTime { get; set; }
+
+ [JsonPropertyName("completedDateTime")]
+ public DateTimeOffset? CompletedDateTime { get; set; }
+
+ [JsonPropertyName("percentComplete")]
+ public int? PercentComplete { get; set; }
+
+ [JsonPropertyName("hasDescription")]
+ public bool? HasDescription { get; set; }
+
+ [JsonPropertyName("assignments")]
+ public JsonElement? Assignments { get; set; } // map keyed by userId
+}
+
+internal sealed class GraphChat
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("chatType")]
+ public string? ChatType { get; set; } // oneOnOne, group, meeting
+
+ [JsonPropertyName("topic")]
+ public string? Topic { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastUpdatedDateTime")]
+ public DateTimeOffset? LastUpdatedDateTime { get; set; }
+
+ [JsonPropertyName("webUrl")]
+ public string? WebUrl { get; set; }
+}
+
+internal sealed class GraphChatMember
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("roles")]
+ public List? Roles { get; set; }
+
+ [JsonPropertyName("visibleHistoryStartDateTime")]
+ public DateTimeOffset? VisibleHistoryStartDateTime { get; set; }
+
+ [JsonPropertyName("userId")]
+ public string? UserId { get; set; } // present on some member types
+}
+
+internal sealed class GraphChatMessage
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ [JsonPropertyName("deletedDateTime")]
+ public DateTimeOffset? DeletedDateTime { get; set; }
+
+ [JsonPropertyName("subject")]
+ public string? Subject { get; set; }
+
+ [JsonPropertyName("body")]
+ public object? Body { get; set; }
+
+ [JsonPropertyName("from")]
+ public object? From { get; set; }
+
+ [JsonPropertyName("attachments")]
+ public object? Attachments { get; set; }
+
+ [JsonPropertyName("mentions")]
+ public object? Mentions { get; set; }
+
+ [JsonPropertyName("reactions")]
+ public object? Reactions { get; set; }
+
+ [JsonPropertyName("replyToId")]
+ public string? ReplyToId { get; set; }
+}
+
+public sealed class GraphChatHostedContent
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("contentType")]
+ public string? ContentType { get; set; }
+
+ [JsonPropertyName("contentBytes")]
+ public string? ContentBytes { get; set; }
+
+ [JsonPropertyName("@microsoft.graph.temporaryId")]
+ public string? TemporaryId { get; set; }
+}
+
+public sealed class GraphGroup
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("mail")]
+ public string? Mail { get; set; }
+
+ [JsonPropertyName("mailEnabled")]
+ public bool? MailEnabled { get; set; }
+
+ [JsonPropertyName("securityEnabled")]
+ public bool? SecurityEnabled { get; set; }
+
+ [JsonPropertyName("visibility")]
+ public string? Visibility { get; set; }
+
+ [JsonPropertyName("groupTypes")]
+ public List? GroupTypes { get; set; }
+
+ [JsonPropertyName("resourceProvisioningOptions")]
+ public List? ResourceProvisioningOptions { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+}
+
+public sealed class GraphDirectoryObject
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("@odata.type")]
+ public string? ODataType { get; set; }
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("userPrincipalName")]
+ public string? UserPrincipalName { get; set; }
+
+ [JsonPropertyName("mail")]
+ public string? Mail { get; set; }
+}
+
+internal sealed class GraphConversation
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("topic")]
+ public string? Topic { get; set; }
+
+ [JsonPropertyName("hasAttachments")]
+ public bool? HasAttachments { get; set; }
+
+ [JsonPropertyName("lastDeliveredDateTime")]
+ public DateTimeOffset? LastDeliveredDateTime { get; set; }
+
+ [JsonPropertyName("uniqueSenders")]
+ public List? UniqueSenders { get; set; }
+
+ [JsonPropertyName("preview")]
+ public string? Preview { get; set; }
+}
+
+internal sealed class GraphConversationThread
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("topic")]
+ public string? Topic { get; set; }
+
+ [JsonPropertyName("lastDeliveredDateTime")]
+ public DateTimeOffset? LastDeliveredDateTime { get; set; }
+
+ [JsonPropertyName("hasAttachments")]
+ public bool? HasAttachments { get; set; }
+
+ [JsonPropertyName("preview")]
+ public string? Preview { get; set; }
+
+ [JsonPropertyName("uniqueSenders")]
+ public List? UniqueSenders { get; set; }
+}
+
+internal sealed class GraphPost
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ [JsonPropertyName("from")]
+ public GraphRecipient? From { get; set; }
+
+ [JsonPropertyName("sender")]
+ public GraphRecipient? Sender { get; set; }
+
+ [JsonPropertyName("body")]
+ public object? Body { get; set; }
+
+ [JsonPropertyName("hasAttachments")]
+ public bool? HasAttachments { get; set; }
+
+ [JsonPropertyName("attachments")]
+ public object? Attachments { get; set; }
+}
+
+public sealed class GraphPlannerPlan
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ // Deprecated in Graph: use Container instead
+ [JsonPropertyName("owner")]
+ public string? Owner { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("createdBy")]
+ public GraphIdentitySet? CreatedBy { get; set; }
+
+ [JsonPropertyName("container")]
+ public GraphPlannerPlanContainer? Container { get; set; }
+}
+
+public sealed class GraphIdentitySet
+{
+ [JsonPropertyName("user")]
+ public GraphIdentity? User { get; set; }
+
+ public sealed class GraphIdentity
+ {
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+ }
+}
+
+public sealed class GraphPlannerPlanContainer
+{
+ [JsonPropertyName("containerId")]
+ public string? ContainerId { get; set; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; } // e.g. "group"
+
+ [JsonPropertyName("url")]
+ public string? Url { get; set; }
+}
+
+public sealed class GraphPlannerBucket
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("planId")]
+ public string? PlanId { get; set; }
+
+ [JsonPropertyName("orderHint")]
+ public string? OrderHint { get; set; }
+}
+
+internal sealed class GraphTeamMember
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("roles")]
+ public List? Roles { get; set; } // contains "owner" for owners
+
+ [JsonPropertyName("visibleHistoryStartDateTime")]
+ public DateTimeOffset? VisibleHistoryStartDateTime { get; set; }
+
+ [JsonPropertyName("email")]
+ public string? Email { get; set; }
+ [JsonPropertyName("userId")]
+ public string? UserId { get; set; }
+
+ [JsonPropertyName("@odata.type")]
+ public string? ODataType { get; set; }
+}
+
+public sealed class GraphChannel
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("membershipType")]
+ public string? MembershipType { get; set; } // standard, private, shared
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("isFavoriteByDefault")]
+ public bool? IsFavoriteByDefault { get; set; }
+
+ [JsonPropertyName("email")]
+ public string? Email { get; set; }
+
+ [JsonPropertyName("webUrl")]
+ public string? WebUrl { get; set; }
+}
+
+public sealed class GraphChannelMessage
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("replyToId")]
+ public string? ReplyToId { get; set; }
+
+ [JsonPropertyName("createdDateTime")]
+ public DateTimeOffset? CreatedDateTime { get; set; }
+
+ [JsonPropertyName("lastModifiedDateTime")]
+ public DateTimeOffset? LastModifiedDateTime { get; set; }
+
+ [JsonPropertyName("deletedDateTime")]
+ public DateTimeOffset? DeletedDateTime { get; set; }
+
+ [JsonPropertyName("subject")]
+ public string? Subject { get; set; }
+
+ [JsonPropertyName("from")]
+ public GraphChatMessageFromIdentitySet? From { get; set; }
+
+ [JsonPropertyName("body")]
+ public object? Body { get; set; }
+
+ [JsonPropertyName("attachments")]
+ public object? Attachments { get; set; }
+
+ [JsonPropertyName("mentions")]
+ public object? Mentions { get; set; }
+
+ [JsonPropertyName("reactions")]
+ public object? Reactions { get; set; }
+}
+
+public sealed class GraphChatMessageFromIdentitySet
+{
+ [JsonPropertyName("user")]
+ public GraphChatMessageIdentity? User { get; set; }
+
+ [JsonPropertyName("application")]
+ public GraphChatMessageIdentity? Application { get; set; }
+
+ [JsonPropertyName("device")]
+ public GraphChatMessageIdentity? Device { get; set; }
+
+ [JsonIgnore]
+ public string DisplayName => User?.DisplayName ?? Application?.DisplayName ?? Device?.DisplayName ?? "";
+}
+
+public sealed class GraphChatMessageIdentity
+{
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("userIdentityType")]
+ public string? UserIdentityType { get; set; }
+}
\ No newline at end of file
diff --git a/proprietary/Office365/Duplicati.Proprietary.Office365.csproj b/proprietary/Office365/Duplicati.Proprietary.Office365.csproj
new file mode 100644
index 000000000..68b2950e5
--- /dev/null
+++ b/proprietary/Office365/Duplicati.Proprietary.Office365.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net10.0
+ Library
+ Duplicati.Proprietary.Office365
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/proprietary/Office365/GraphSelectBuilder.cs b/proprietary/Office365/GraphSelectBuilder.cs
new file mode 100644
index 000000000..e09c70c99
--- /dev/null
+++ b/proprietary/Office365/GraphSelectBuilder.cs
@@ -0,0 +1,91 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Collections.Concurrent;
+using System.Reflection;
+using System.Text.Json.Serialization;
+
+namespace Duplicati.Proprietary.Office365;
+
+internal static class GraphSelectBuilder
+{
+ ///
+ /// Cache of built select strings per type.
+ ///
+ private static readonly ConcurrentDictionary _cache = new();
+
+ ///
+ /// Builds an OData $select list from a CLR type by reading [JsonPropertyName] attributes.
+ /// Falls back to camelCase of the property name if [JsonPropertyName] is missing.
+ /// Excludes properties marked with [JsonIgnore].
+ ///
+ /// The type to build the select for.
+ /// The OData $select string.
+ internal static string BuildSelect(IEnumerable? exclude = null) => BuildSelect(typeof(T), exclude);
+
+ ///
+ /// Builds an OData $select list from a CLR type by reading [JsonPropertyName] attributes.
+ /// Falls back to camelCase of the property name if [JsonPropertyName] is missing.
+ /// Excludes properties marked with [JsonIgnore].
+ ///
+ /// The type to build the select for.
+ /// The OData $select string.
+ internal static string BuildSelect(Type type, IEnumerable? exclude = null)
+ {
+ var names = _cache.GetOrAdd(type, BuildSelectInternal);
+ if (exclude == null || !exclude.Any()) return string.Join(",", names);
+ return string.Join(",", names.Except(exclude, StringComparer.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Builds an OData $select list from a CLR type by reading [JsonPropertyName] attributes.
+ /// Falls back to camelCase of the property name if [JsonPropertyName] is missing.
+ /// Excludes properties marked with [JsonIgnore].
+ ///
+ /// The type to build the select for.
+ /// The OData $select string.
+ private static string[] BuildSelectInternal(Type type)
+ {
+ if (type == null) throw new ArgumentNullException(nameof(type));
+
+ static string ToCamelCase(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return s;
+ if (s.Length == 1) return char.ToLowerInvariant(s[0]).ToString();
+ return char.ToLowerInvariant(s[0]) + s.Substring(1);
+ }
+
+ var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
+ var names = new List(props.Length);
+
+ foreach (var p in props)
+ {
+ if (!p.CanRead) continue;
+
+ // Skip [JsonIgnore]
+ var ignore = p.GetCustomAttribute();
+ if (ignore != null && ignore.Condition != JsonIgnoreCondition.Never)
+ continue;
+
+ // Skip indexers
+ if (p.GetIndexParameters().Length != 0)
+ continue;
+
+ var jp = p.GetCustomAttribute();
+ var name = jp?.Name;
+
+ if (string.IsNullOrWhiteSpace(name))
+ name = ToCamelCase(p.Name);
+
+ // Skip special JSON metadata fields if any
+ if (name.StartsWith("@", StringComparison.Ordinal))
+ continue;
+
+ names.Add(name);
+ }
+
+ // Deterministic output for caching/testability
+ names.Sort(StringComparer.Ordinal);
+
+ return names.ToArray();
+ }
+}
\ No newline at end of file
diff --git a/proprietary/Office365/GraphUtils.cs b/proprietary/Office365/GraphUtils.cs
new file mode 100644
index 000000000..9414d78bc
--- /dev/null
+++ b/proprietary/Office365/GraphUtils.cs
@@ -0,0 +1,16 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+namespace Duplicati.Proprietary.Office365;
+
+public static class GraphUtils
+{
+ public static DateTime FromGraphDateTime(this DateTimeOffset? dto)
+ => dto?.UtcDateTime ?? DateTime.UnixEpoch;
+
+ public static string SanitizePathComponent(string pathComponent)
+ {
+ var invalidChars = Path.GetInvalidFileNameChars();
+ var sanitized = new string(pathComponent.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray());
+ return sanitized;
+ }
+}
diff --git a/proprietary/Office365/OptionsHelper.cs b/proprietary/Office365/OptionsHelper.cs
new file mode 100644
index 000000000..b06fa35c8
--- /dev/null
+++ b/proprietary/Office365/OptionsHelper.cs
@@ -0,0 +1,51 @@
+using Duplicati.Library.Interface;
+using Duplicati.Library.Utility.Options;
+
+namespace Duplicati.Proprietary.Office365;
+
+internal static class OptionsHelper
+{
+ internal const string ModuleKey = "office365";
+ internal const string OFFICE_TENANT_OPTION = "office-tenant-id";
+ internal const string OFFICE_CLIENT_OPTION = "office-client-id";
+ internal const string OFFICE_SECRET_OPTION = "office-client-secret";
+ internal const string OFFICE_GRAPH_BASE_OPTION = "office-graph-base-url";
+
+ internal const string DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com";
+
+ internal const int GENERAL_PAGE_SIZE = 100;
+ internal const int CHATS_PAGE_SIZE = 50;
+
+ internal sealed record ParsedOptions(
+ string TenantId,
+ AuthOptionsHelper.AuthOptions AuthOptions,
+ string GraphBaseUrl
+ );
+
+ internal static ParsedOptions ParseAndValidateOptions(string url, Dictionary options)
+ {
+ var _authOptions = AuthOptionsHelper.ParseWithAlias(options, new Library.Utility.Uri(url), OFFICE_CLIENT_OPTION, OFFICE_SECRET_OPTION)
+ .RequireCredentials();
+ var _tenantId = options.GetValueOrDefault(OFFICE_TENANT_OPTION)!;
+ if (string.IsNullOrWhiteSpace(_tenantId))
+ throw new UserInformationException(Strings.MissingTenantId, "MissingTenantId");
+
+ var _graphBaseUrl = options.GetValueOrDefault(OFFICE_GRAPH_BASE_OPTION) ?? DEFAULT_GRAPH_BASE_URL;
+ if (string.IsNullOrWhiteSpace(_graphBaseUrl))
+ _graphBaseUrl = DEFAULT_GRAPH_BASE_URL;
+
+ return new ParsedOptions(
+ TenantId: _tenantId,
+ AuthOptions: _authOptions,
+ GraphBaseUrl: _graphBaseUrl
+ );
+ }
+
+ internal static IList SupportedCommands =>
+ [
+ new CommandLineArgument(OFFICE_TENANT_OPTION, CommandLineArgument.ArgumentType.String, Strings.OfficeTenantOptionShort, Strings.OfficeTenantOptionLong),
+ new CommandLineArgument(OFFICE_CLIENT_OPTION, CommandLineArgument.ArgumentType.String, Strings.OfficeClientOptionShort, Strings.OfficeClientOptionLong, null, [AuthOptionsHelper.AuthUsernameOption], null),
+ new CommandLineArgument(OFFICE_SECRET_OPTION, CommandLineArgument.ArgumentType.Password, Strings.OfficeSecretOptionShort, Strings.OfficeSecretOptionLong, null, [AuthOptionsHelper.AuthPasswordOption], null),
+ new CommandLineArgument(OFFICE_GRAPH_BASE_OPTION, CommandLineArgument.ArgumentType.String, Strings.OfficeGraphBaseOptionShort, Strings.OfficeGraphBaseOptionLong, DEFAULT_GRAPH_BASE_URL)
+ ];
+}
\ No newline at end of file
diff --git a/proprietary/Office365/SourceItems/CalendarEventSourceEntry.cs b/proprietary/Office365/SourceItems/CalendarEventSourceEntry.cs
new file mode 100644
index 000000000..6cdb06ca6
--- /dev/null
+++ b/proprietary/Office365/SourceItems/CalendarEventSourceEntry.cs
@@ -0,0 +1,26 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class CalendarEventSourceEntry(SourceProvider provider, string path, GraphCalendarGroup calendarGroup, GraphCalendar calendar, GraphEvent eventItem)
+ : StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(path, eventItem.Id),
+ eventItem.CreatedDateTime.FromGraphDateTime(),
+ eventItem.LastModifiedDateTime.FromGraphDateTime(),
+ -1,
+ ct => provider.CalendarApi.GetCalendarEventStreamAsync(calendarGroup.Id, calendar.Id, eventItem.Id, ct),
+ minorMetadataFactory: ct => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", eventItem.Id },
+ { "o365:Name", eventItem.Subject ?? "" },
+ { "o365:Type", SourceItemType.CalendarEvent.ToString() },
+ { "o365:Subject", eventItem.Subject ?? "" },
+ { "o365:Start", eventItem.Start?.ToString() ??"" },
+ { "o365:End", eventItem.End?.ToString() ?? "" },
+ }.Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)))
+{
+}
diff --git a/proprietary/Office365/SourceItems/CalendarGroupSourceEntry.cs b/proprietary/Office365/SourceItems/CalendarGroupSourceEntry.cs
new file mode 100644
index 000000000..0220e2cb4
--- /dev/null
+++ b/proprietary/Office365/SourceItems/CalendarGroupSourceEntry.cs
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class CalendarGroupSourceEntry(SourceProvider provider, string path, GraphUser user, GraphCalendarGroup group)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, group.Id)), null, null)
+{
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", group.Id },
+ { "o365:Type", SourceItemType.CalendarGroup.ToString() },
+ { "o365:Name", group.Name ?? "" },
+ { "o365:ClassId", group.ClassId ?? "" },
+ { "o365:ChangeKey", group.ChangeKey ?? "" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var calendar in provider.CalendarApi.ListUserCalendarsInCalendarGroupAsync(user.Id, group.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new CalendarSourceEntry(provider, this.Path, user, group, calendar);
+ }
+ }
+}
diff --git a/proprietary/Office365/SourceItems/CalendarSourceEntry.cs b/proprietary/Office365/SourceItems/CalendarSourceEntry.cs
new file mode 100644
index 000000000..336825c46
--- /dev/null
+++ b/proprietary/Office365/SourceItems/CalendarSourceEntry.cs
@@ -0,0 +1,46 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class CalendarSourceEntry(SourceProvider provider, string path, GraphUser user, GraphCalendarGroup calendarGroup, GraphCalendar calendar)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, calendar.Id)), null, null)
+{
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", calendar.Id },
+ { "o365:Name", calendar.Name ?? "" },
+ { "o365:Type", SourceItemType.Calendar.ToString() },
+ { "o365:ChangeKey", calendar.ChangeKey ?? "" },
+ { "o365:Color", calendar.Color ?? "" },
+ { "o365:HexColor", calendar.HexColor ?? "" },
+ { "o365:IsDefaultCalendar", calendar.IsDefaultCalendar.ToString() ?? "" },
+ { "o365:IsRemovable", calendar.IsRemovable.ToString() ?? "" },
+ { "o365:CanEdit", calendar.CanEdit.ToString() ?? "" },
+ { "o365:CanShare", calendar.CanShare.ToString() ?? "" },
+ { "o365:CanViewPrivateItems", calendar.CanViewPrivateItems.ToString() ?? "" },
+ { "o365:IsTallyingResponses", calendar.IsTallyingResponses.ToString() ?? "" },
+ { "o365:DefaultOnlineMeetingProvider", calendar.DefaultOnlineMeetingProvider ?? "" },
+ { "o365:AllowedOnlineMeetingProviders", JsonSerializer.Serialize(calendar.AllowedOnlineMeetingProviders) ?? "" },
+ { "o365:Owner", JsonSerializer.Serialize(calendar.Owner) ?? "" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var eventItem in provider.CalendarApi.ListCalendarEventsAsync(user.Id, calendar.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new CalendarEventSourceEntry(provider, this.Path, calendarGroup, calendar, eventItem);
+ }
+ }
+}
diff --git a/proprietary/Office365/SourceItems/ChatEntriesSourceEntry.cs b/proprietary/Office365/SourceItems/ChatEntriesSourceEntry.cs
new file mode 100644
index 000000000..ea668a5d0
--- /dev/null
+++ b/proprietary/Office365/SourceItems/ChatEntriesSourceEntry.cs
@@ -0,0 +1,55 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class ChatEntriesSourceEntry(SourceProvider provider, string path, GraphChat chat, ChatEntryType type)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, type.ToString().ToLowerInvariant())), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (type == ChatEntryType.Message)
+ {
+ await foreach (var message in provider.ChatApi.ListChatMessagesAsync(chat.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new ChatMessageSourceEntry(provider, this.Path, chat, message);
+ }
+ }
+ else if (type == ChatEntryType.Member)
+ {
+ await foreach (var member in provider.ChatApi.ListChatMembersAsync(chat.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, member.Id + ".json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: async (ct) =>
+ {
+ var ms = new MemoryStream();
+ await System.Text.Json.JsonSerializer.SerializeAsync(ms, member, cancellationToken: ct).ConfigureAwait(false);
+ ms.Seek(0, SeekOrigin.Begin);
+ return ms;
+ },
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", member.Id },
+ { "o365:Type", SourceItemType.ChatMember.ToString() },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+ }
+ }
+ else
+ throw new NotImplementedException();
+ }
+}
diff --git a/proprietary/Office365/SourceItems/ChatHostedContentSourceEntry.cs b/proprietary/Office365/SourceItems/ChatHostedContentSourceEntry.cs
new file mode 100644
index 000000000..044cf58d5
--- /dev/null
+++ b/proprietary/Office365/SourceItems/ChatHostedContentSourceEntry.cs
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class ChatHostedContentSourceEntry(SourceProvider provider, string path, GraphChat chat, GraphChatMessage message)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "hostedcontent")), null, null)
+{
+ public async override IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var attachment in provider.ChatApi.ListChatHostedContentsAsync(chat.Id, message.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, attachment.Id + ".json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: async (ct) => await provider.ChatApi.GetChatHostedContentValueStreamAsync(chat.Id, message.Id, attachment.Id, ct).ConfigureAwait(false),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ ["o365:v"] = "1",
+ ["o365:Id"] = attachment.Id,
+ ["o365:Type"] = SourceItemType.ChatHostedContent.ToString(),
+ ["o365:ContentType"] = attachment.ContentType ?? "",
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+ }
+ }
+}
diff --git a/proprietary/Office365/SourceItems/ChatMessageSourceEntry.cs b/proprietary/Office365/SourceItems/ChatMessageSourceEntry.cs
new file mode 100644
index 000000000..dcced3a09
--- /dev/null
+++ b/proprietary/Office365/SourceItems/ChatMessageSourceEntry.cs
@@ -0,0 +1,38 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class ChatMessageSourceEntry(SourceProvider provider, string path, GraphChat chat, GraphChatMessage message)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, message.Id)), message.CreatedDateTime.FromGraphDateTime(), message.LastModifiedDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: async (ct) =>
+ {
+ var ms = new MemoryStream();
+ await System.Text.Json.JsonSerializer.SerializeAsync(ms, message, cancellationToken: ct).ConfigureAwait(false);
+ ms.Seek(0, SeekOrigin.Begin);
+ return ms;
+ },
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", message.Id },
+ { "o365:Type", SourceItemType.ChatMessage.ToString() },
+ { "o365:Name", message.Subject ?? "" },
+ { "o365:From", message.From?.ToString() ?? "" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+
+ yield return new ChatHostedContentSourceEntry(provider, this.Path, chat, message);
+ }
+}
diff --git a/proprietary/Office365/SourceItems/ChatSourceEntry.cs b/proprietary/Office365/SourceItems/ChatSourceEntry.cs
new file mode 100644
index 000000000..e57a8bb4b
--- /dev/null
+++ b/proprietary/Office365/SourceItems/ChatSourceEntry.cs
@@ -0,0 +1,42 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal enum ChatEntryType
+{
+ Message,
+ Member
+}
+
+internal class ChatSourceEntry(SourceProvider provider, string path, GraphChat chat)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, chat.Id)), chat.CreatedDateTime.FromGraphDateTime(), chat.LastUpdatedDateTime.FromGraphDateTime())
+{
+ public async override IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var ms = new MemoryStream();
+ System.Text.Json.JsonSerializer.Serialize(ms, chat);
+ ms.Seek(0, SeekOrigin.Begin);
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => Task.FromResult(ms),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", chat.Id },
+ { "o365:Type", SourceItemType.Chat.ToString() },
+ { "o365:Name", chat.Topic ?? "" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+
+ yield return new ChatEntriesSourceEntry(provider, this.Path, chat, ChatEntryType.Message);
+ yield return new ChatEntriesSourceEntry(provider, this.Path, chat, ChatEntryType.Member);
+ }
+}
diff --git a/proprietary/Office365/SourceItems/DriveFileSourceEntry.cs b/proprietary/Office365/SourceItems/DriveFileSourceEntry.cs
new file mode 100644
index 000000000..7dea00a68
--- /dev/null
+++ b/proprietary/Office365/SourceItems/DriveFileSourceEntry.cs
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class DriveFileSourceEntry(SourceProvider provider, string path, GraphDrive drive, GraphDriveItem item)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(path, item.Id))
+{
+ public override DateTime CreatedUtc => item.CreatedDateTime.FromGraphDateTime();
+
+ public override DateTime LastModificationUtc => item.LastModifiedDateTime.FromGraphDateTime();
+
+ public override long Size => item.Size ?? -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ => provider.OneDriveApi.GetDriveItemContentStreamAsync(drive.Id, item.Id, cancellationToken);
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", item.Id },
+ { "o365:Type", SourceItemType.DriveFile.ToString() },
+ { "o365:Name", item.Name ?? "" },
+ { "o365:ETag", item.ETag ?? "" },
+ { "o365:CTag", item.CTag ?? "" },
+ { "o365:MimeType", item.File?.MimeType ?? "" },
+ { "o365:ParentReference", JsonSerializer.Serialize(item.ParentReference) ?? "" },
+ { "o365:FileSystemInfo", JsonSerializer.Serialize(item.FileSystemInfo) ?? "" },
+ { "o365:DownloadUrl", item.DownloadUrl ?? "" },
+ { "o365:Hashes", JsonSerializer.Serialize(item.File?.Hashes) ?? "" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+}
diff --git a/proprietary/Office365/SourceItems/DriveFolderSourceEntry.cs b/proprietary/Office365/SourceItems/DriveFolderSourceEntry.cs
new file mode 100644
index 000000000..6ab6b3bf4
--- /dev/null
+++ b/proprietary/Office365/SourceItems/DriveFolderSourceEntry.cs
@@ -0,0 +1,55 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Logging;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class DriveFolderSourceEntry(SourceProvider provider, string path, GraphDrive drive, GraphDriveItem item)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, item.Id)), item.CreatedDateTime.FromGraphDateTime(), item.LastModifiedDateTime.FromGraphDateTime())
+{
+ private static readonly string LOGTAG = Log.LogTagFromType();
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var child in provider.OneDriveApi.ListDriveFolderChildrenAsync(drive.Id, item.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ if (child.Deleted != null)
+ continue;
+ if (child.Folder != null)
+ yield return new DriveFolderSourceEntry(provider, this.Path, drive, child);
+ else if (child.File != null)
+ yield return new DriveFileSourceEntry(provider, this.Path, drive, child);
+ else
+ Log.WriteWarningMessage(LOGTAG, "UnknownDriveItemType", null, $"Unknown drive item type: {item.Id}");
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", item.Id },
+ { "o365:Type", SourceItemType.DriveFolder.ToString() },
+ { "o365:Name", item.Name ?? "" },
+ { "o365:FolderChildCount", item.Folder?.ChildCount.ToString() ?? "" },
+ { "o365:ETag", item.ETag ?? "" },
+ { "o365:CTag", item.CTag ?? "" },
+ { "o365:ParentReference", JsonSerializer.Serialize(item.ParentReference) ?? "" },
+ { "o365:FileSystemInfo", JsonSerializer.Serialize(item.FileSystemInfo) ?? "" },
+ { "o365:DownloadUrl", item.DownloadUrl ?? "" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+
+ public override Task FileExists(string filename, CancellationToken cancellationToken)
+ {
+ // TODO: Implement this
+ return Task.FromResult(false);
+ }
+}
diff --git a/proprietary/Office365/SourceItems/DriveSourceEntry.cs b/proprietary/Office365/SourceItems/DriveSourceEntry.cs
new file mode 100644
index 000000000..b60dffcf7
--- /dev/null
+++ b/proprietary/Office365/SourceItems/DriveSourceEntry.cs
@@ -0,0 +1,54 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Logging;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class DriveSourceEntry(SourceProvider provider, string path, GraphDrive drive)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, drive.Id)), drive.CreatedDateTime.FromGraphDateTime(), drive.LastModifiedDateTime.FromGraphDateTime())
+{
+ private static readonly string LOGTAG = Log.LogTagFromType();
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var item in provider.OneDriveApi.ListDriveRootChildrenAsync(drive.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ if (item.Deleted != null)
+ continue;
+
+ if (item.Folder != null)
+ yield return new DriveFolderSourceEntry(provider, Path, drive, item);
+ else if (item.File != null)
+ yield return new DriveFileSourceEntry(provider, Path, drive, item);
+ else
+ Log.WriteWarningMessage(LOGTAG, "UnknownDriveItemType", null, $"Unknown drive item type: {item.Id}");
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", drive.Id },
+ { "o365:Type", SourceItemType.Drive.ToString() },
+ { "o365:Name", drive.Name ?? "" },
+ { "o365:Description", drive.Description ?? "" },
+ { "o365:WebUrl", drive.WebUrl ?? "" },
+ { "o365:CreatedBy", JsonSerializer.Serialize(drive.CreatedBy) ?? "" },
+ { "o365:LastModifiedBy", JsonSerializer.Serialize(drive.LastModifiedBy) ?? "" },
+ { "o365:DriveType", drive.DriveType ?? "" },
+ { "o365:Owner", JsonSerializer.Serialize(drive.Owner) ?? "" },
+ { "o365:Quota", JsonSerializer.Serialize(drive.Quota) ?? "" },
+ { "o365:SharePointIds", JsonSerializer.Serialize(drive.SharePointIds) ?? "" },
+ { "o365:System", JsonSerializer.Serialize(drive.System) ?? "" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+}
diff --git a/proprietary/Office365/SourceItems/GroupCalendarEventsSourceEntry.cs b/proprietary/Office365/SourceItems/GroupCalendarEventsSourceEntry.cs
new file mode 100644
index 000000000..002a6da52
--- /dev/null
+++ b/proprietary/Office365/SourceItems/GroupCalendarEventsSourceEntry.cs
@@ -0,0 +1,25 @@
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class GroupCalendarEventsSourceEntry(SourceProvider provider, string path, GraphGroup group)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "events")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var entry in provider.GroupCalendarApi.ListGroupEventsAsync(group.Id, cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, entry.Id + ".ics"),
+ createdUtc: entry.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: entry.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.GroupCalendarApi.GetGroupEventStreamAsync(group.Id, entry.Id, ct));
+ }
+ }
+}
diff --git a/proprietary/Office365/SourceItems/GroupChannelMessageReplySourceEntry.cs b/proprietary/Office365/SourceItems/GroupChannelMessageReplySourceEntry.cs
new file mode 100644
index 000000000..f623395c6
--- /dev/null
+++ b/proprietary/Office365/SourceItems/GroupChannelMessageReplySourceEntry.cs
@@ -0,0 +1,38 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class GroupChannelMessageReplySourceEntry(SourceProvider provider, string path, GraphGroup group, GraphChannel channel, GraphChannelMessage message)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "replies")), message.CreatedDateTime.FromGraphDateTime(), message.LastModifiedDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var reply in provider.GroupTeamsApi.ListChannelMessageRepliesAsync(group.Id, channel.Id, message.Id, cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, reply.Id),
+ createdUtc: reply.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: reply.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.GroupTeamsApi.GetChannelMessageReplyStreamAsync(group.Id, channel.Id, message.Id, reply.Id, ct),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", reply.Id },
+ { "o365:Type", SourceItemType.GroupChannelMessageReply.ToString() },
+ { "o365:Name", $"Re: {reply.From?.DisplayName} - {reply.Subject}" },
+ { "o365:Subject", reply.Subject },
+ { "o365:From", reply.From?.ToString() ?? "" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+ }
+ }
+}
\ No newline at end of file
diff --git a/proprietary/Office365/SourceItems/GroupChannelMessageSourceEntry.cs b/proprietary/Office365/SourceItems/GroupChannelMessageSourceEntry.cs
new file mode 100644
index 000000000..298e43739
--- /dev/null
+++ b/proprietary/Office365/SourceItems/GroupChannelMessageSourceEntry.cs
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class GroupChannelMessageSourceEntry(SourceProvider provider, string path, GraphGroup group, GraphChannel channel, GraphChannelMessage message)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, message.Id)), message.CreatedDateTime.FromGraphDateTime(), message.LastModifiedDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
+ createdUtc: message.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: message.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.GroupTeamsApi.GetChannelMessageStreamAsync(group.Id, channel.Id, message.Id, ct),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", message.Id },
+ { "o365:Type", SourceItemType.GroupChannelMessage.ToString() },
+ { "o365:Name", $"{message.From?.DisplayName} - {message.Subject}" },
+ { "o365:Subject", message.Subject },
+ { "o365:From", message.From?.ToString() ?? "" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+
+ yield return new GroupChannelMessageReplySourceEntry(provider, this.Path, group, channel, message);
+ }
+}
diff --git a/proprietary/Office365/SourceItems/GroupChannelSourceEntry.cs b/proprietary/Office365/SourceItems/GroupChannelSourceEntry.cs
new file mode 100644
index 000000000..d6c041dc2
--- /dev/null
+++ b/proprietary/Office365/SourceItems/GroupChannelSourceEntry.cs
@@ -0,0 +1,41 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class GroupChannelSourceEntry(SourceProvider provider, string path, GraphGroup group, GraphChannel channel)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, channel.Id)), channel.CreatedDateTime.FromGraphDateTime(), null)
+{
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", channel.Id },
+ { "o365:Type", SourceItemType.GroupChannel.ToString() },
+ { "o365:Name", channel.DisplayName },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.GroupTeamsApi.GetTeamChannelStreamAsync(group.Id, channel.Id, ct));
+
+ await foreach (var message in provider.GroupTeamsApi.ListChannelMessagesAsync(group.Id, channel.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new GroupChannelMessageSourceEntry(provider, this.Path, group, channel, message);
+
+ }
+ }
+}
diff --git a/proprietary/Office365/SourceItems/GroupConversationSourceEntry.cs b/proprietary/Office365/SourceItems/GroupConversationSourceEntry.cs
new file mode 100644
index 000000000..0eb1f35f6
--- /dev/null
+++ b/proprietary/Office365/SourceItems/GroupConversationSourceEntry.cs
@@ -0,0 +1,36 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class GroupConversationSourceEntry(SourceProvider provider, GraphGroup group, string path, GraphConversation conversation)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, conversation.Id)), null, conversation.LastDeliveredDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var thread in provider.GroupConversationApi.ListConversationThreadsAsync(group.Id, conversation.Id, cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new GroupConversationThreadSourceEntry(provider, this.Path, group, thread);
+ }
+ }
+
+ override public Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", conversation.Id },
+ { "o365:Type", SourceItemType.GroupConversation.ToString() },
+ { "o365:Name", conversation.Topic },
+ { "o365:Topic", conversation.Topic },
+ { "o365:HasAttachments", conversation.HasAttachments.ToString() },
+ { "o365:LastDeliveredDateTime", conversation.LastDeliveredDateTime.FromGraphDateTime().ToString("o") }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+}
diff --git a/proprietary/Office365/SourceItems/GroupConversationThread.cs b/proprietary/Office365/SourceItems/GroupConversationThread.cs
new file mode 100644
index 000000000..55c6466c4
--- /dev/null
+++ b/proprietary/Office365/SourceItems/GroupConversationThread.cs
@@ -0,0 +1,54 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Utility;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class GroupConversationThreadSourceEntry(SourceProvider provider, string path, GraphGroup group, GraphConversationThread thread)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, thread.Id)), null, thread.LastDeliveredDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var post in provider.GroupConversationApi.ListThreadPostsAsync(group.Id, thread.Id, cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, post.Id + ".json"),
+ createdUtc: post.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: post.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.GroupConversationApi.GetThreadPostStreamAsync(group.Id, thread.Id, post.Id, ct),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", post.Id },
+ { "o365:Type", SourceItemType.GroupConversationThreadPost.ToString() },
+ { "o365:Name", $"{post.From?.EmailAddress?.Name} - {post.CreatedDateTime.FromGraphDateTime()}" },
+ { "o365:From", post.From?.ToString() },
+ { "o365:CreatedDateTime", post.CreatedDateTime.FromGraphDateTime().ToString("o") },
+ { "o365:LastModifiedDateTime", post.LastModifiedDateTime.FromGraphDateTime().ToString("o") }
+ }
+ .WhereNotNull()
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
+ );
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", thread.Id },
+ { "o365:Type", SourceItemType.GroupConversationThread.ToString() },
+ { "o365:Name", $"Thread: {thread.Topic ?? ""}" },
+ { "o365:Topic", thread.Topic },
+ { "o365:LastDeliveredDateTime", thread.LastDeliveredDateTime.FromGraphDateTime().ToString("o") }
+ }
+ .WhereNotNull()
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value));
+}
diff --git a/proprietary/Office365/SourceItems/GroupSourceEntry.cs b/proprietary/Office365/SourceItems/GroupSourceEntry.cs
new file mode 100644
index 000000000..314afbf8e
--- /dev/null
+++ b/proprietary/Office365/SourceItems/GroupSourceEntry.cs
@@ -0,0 +1,100 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Utility;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+
+internal enum Office365GroupType
+{
+ Mailbox,
+ Calendar,
+ Files,
+ Planner,
+ Teams
+}
+
+internal class GroupSourceEntry(SourceProvider provider, string path, GraphGroup group)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, group.Id)), group.CreatedDateTime.FromGraphDateTime(), null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var entry in MetadataEntries(cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return entry;
+ }
+
+ foreach (var type in provider.IncludedGroupTypes)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new GroupTypeSourceEntry(provider, Path, group, type);
+ }
+ }
+
+ private async IAsyncEnumerable MetadataEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var res = new StreamResourceEntryFunction[] {
+ new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.GroupApi.GetGroupMetadataStreamAsync(group.Id, ct)),
+
+ new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "members.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => DirectoryEntriesAsStream(provider.GroupApi.ListGroupMembersAsync(group.Id, ct), ct)),
+
+ new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "owners.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => DirectoryEntriesAsStream(provider.GroupApi.ListGroupOwnersAsync(group.Id, ct), ct)),
+ };
+
+ foreach (var entry in res)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return entry;
+ }
+ }
+
+ private async Task DirectoryEntriesAsStream(IAsyncEnumerable items, CancellationToken cancellationToken)
+ {
+ var stream = new MemoryStream();
+ JsonSerializer.Serialize(stream, await items.ToListAsync(cancellationToken));
+ stream.Position = 0;
+ return stream;
+ }
+
+ public override Task FileExists(string filename, CancellationToken cancellationToken)
+ => Task.FromResult(provider.IncludedGroupTypes.Any(x => x.ToString() == filename));
+
+ override public Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", group.Id },
+ { "o365:Type", SourceItemType.Group.ToString() },
+ { "o365:Name", group.DisplayName },
+ { "o365:Description", group.Description },
+ { "o365:CreatedDateTime", group.CreatedDateTime.FromGraphDateTime().ToString("o") },
+ { "o365:Visibility", group.Visibility },
+ { "o365:MailEnabled", group.MailEnabled.ToString() },
+ { "o365:SecurityEnabled", group.SecurityEnabled.ToString() },
+ }
+ .WhereNotNull()
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value));
+}
diff --git a/proprietary/Office365/SourceItems/GroupTypeSourceEntry.cs b/proprietary/Office365/SourceItems/GroupTypeSourceEntry.cs
new file mode 100644
index 000000000..d0290c099
--- /dev/null
+++ b/proprietary/Office365/SourceItems/GroupTypeSourceEntry.cs
@@ -0,0 +1,164 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Logging;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class GroupTypeSourceEntry(SourceProvider provider, string path, GraphGroup group, Office365GroupType groupType)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, groupType.ToString().ToLowerInvariant())), group.CreatedDateTime.FromGraphDateTime(), null)
+{
+ private static readonly string LOGTAG = Log.LogTagFromType();
+
+ public override bool IsMetaEntry
+ {
+ get
+ {
+ // Minor hack to skip Teams meta entry if the group is not a Team
+ if (groupType != Office365GroupType.Teams)
+ return false;
+
+ // Skip if the group is not a Team
+ if (!group.ResourceProvisioningOptions?.Contains("Team", StringComparer.OrdinalIgnoreCase) == true)
+ return true;
+
+ return false;
+ }
+ }
+
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var entries = groupType switch
+ {
+ Office365GroupType.Mailbox => MailboxEntries(cancellationToken),
+ Office365GroupType.Calendar => CalendarEntries(cancellationToken),
+ Office365GroupType.Files => FilesEntries(cancellationToken),
+ Office365GroupType.Planner => PlannerEntries(cancellationToken),
+ Office365GroupType.Teams => TeamsEntries(cancellationToken),
+
+ _ => null
+ };
+
+ if (entries is null)
+ {
+ Log.WriteWarningMessage(LOGTAG, "UnknownGroupType", null, $"Unknown group type '{groupType}' for group '{group.Id}'");
+ yield break;
+ }
+
+ await foreach (var entry in entries)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ // Probe if the entry exists, if it is a stream resource
+ if (entry is StreamResourceEntryFunction sref)
+ if (!await sref.ProbeIfExistsAsync(cancellationToken).ConfigureAwait(false))
+ continue;
+
+ yield return entry;
+ }
+ }
+
+ private async IAsyncEnumerable MailboxEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if ((group.GroupTypes?.Contains("Unified") ?? false) && (group.MailEnabled ?? false))
+ {
+ await foreach (var entry in provider.GroupConversationApi.ListGroupConversationsAsync(group.Id, cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new GroupConversationSourceEntry(provider, group, this.Path, entry);
+ }
+ }
+ }
+
+ private async IAsyncEnumerable CalendarEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.GroupCalendarApi.GetGroupCalendarStreamAsync(group.Id, ct),
+ minorMetadataFactory: async (ct) =>
+ {
+ var calendar = await provider.GroupCalendarApi.GetGroupCalendarAsync(group.Id, ct).ConfigureAwait(false);
+ return new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", calendar.Id },
+ { "o365:Type", SourceItemType.GroupCalendar.ToString() },
+ { "o365:Name", calendar.Name ?? "" }
+ };
+ });
+
+ yield return new GroupCalendarEventsSourceEntry(provider, this.Path, group);
+ }
+
+ private async IAsyncEnumerable FilesEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var drive in provider.GroupDriveApi.ListGroupDrivesAsync(group.Id, cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new DriveSourceEntry(provider, this.Path, drive);
+ }
+ }
+
+ private async IAsyncEnumerable PlannerEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var plan in provider.GroupPlannerApi.ListGroupPlannerPlansAsync(group.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new PlannerPlanSourceEntry(provider, this.Path, plan);
+ }
+ }
+
+ private async IAsyncEnumerable TeamsEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ // Skip if the group is not a Team
+ if (!group.ResourceProvisioningOptions?.Contains("Team", StringComparer.OrdinalIgnoreCase) == true)
+ yield break;
+
+ // It can be provisioned as a Team but not have a Team yet
+ if (!await provider.GroupTeamsApi.IsGroupTeamAsync(group.Id, cancellationToken))
+ yield break;
+
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.GroupTeamsApi.GetTeamMetadataStreamAsync(group.Id, ct));
+
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "members.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: async (ct) =>
+ {
+ var ms = new MemoryStream();
+ await JsonSerializer.SerializeAsync(ms, provider.GroupTeamsApi.ListTeamMembersAsync(group.Id, ct).ToListAsync(ct), cancellationToken: ct);
+ ms.Position = 0;
+ return ms;
+ });
+
+ await foreach (var channel in provider.GroupTeamsApi.ListTeamChannelsAsync(group.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new GroupChannelSourceEntry(provider, this.Path, group, channel);
+ }
+
+ }
+}
diff --git a/proprietary/Office365/SourceItems/MetaEntryBase.cs b/proprietary/Office365/SourceItems/MetaEntryBase.cs
new file mode 100644
index 000000000..e001ff7a4
--- /dev/null
+++ b/proprietary/Office365/SourceItems/MetaEntryBase.cs
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal abstract class MetaEntryBase(string path, DateTime? createdUtc, DateTime? lastModificationUtc) : ISourceProviderEntry
+{
+ public bool IsFolder => true;
+
+ public virtual bool IsMetaEntry => false;
+
+ public bool IsRootEntry => false;
+
+ public DateTime CreatedUtc => createdUtc ?? DateTime.UnixEpoch;
+
+ public DateTime LastModificationUtc => lastModificationUtc ?? DateTime.UnixEpoch;
+
+ public string Path => path;
+
+ public long Size => -1;
+ public bool IsSymlink => false;
+
+ public string? SymlinkTarget => null;
+
+ public FileAttributes Attributes => FileAttributes.Directory;
+
+ public bool IsBlockDevice => false;
+
+ public bool IsCharacterDevice => false;
+
+ public bool IsAlternateStream => false;
+ public string? HardlinkTargetId => null;
+
+ public abstract IAsyncEnumerable Enumerate(CancellationToken cancellationToken);
+
+ public virtual Task FileExists(string filename, CancellationToken cancellationToken)
+ => Task.FromResult(false);
+
+ public virtual Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary());
+
+ public Task OpenRead(CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/proprietary/Office365/SourceItems/MetaRootSourceEntry.cs b/proprietary/Office365/SourceItems/MetaRootSourceEntry.cs
new file mode 100644
index 000000000..b59b00aec
--- /dev/null
+++ b/proprietary/Office365/SourceItems/MetaRootSourceEntry.cs
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Logging;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal enum Office365MetaType
+{
+ // Tenant directory / discovery roots
+ Users,
+ Groups,
+ Sites,
+
+ // // SharePoint structured content
+ // Lists, // SharePoint lists
+ // ListItems, // SharePoint list rows/items
+
+ // // Compliance/audit
+ // AuditLogBlobs // Management Activity API content items
+}
+
+internal class MetaRootSourceEntry(SourceProvider provider, string mountPoint, Office365MetaType type)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(mountPoint, type.ToString().ToLower())), null, null)
+{
+ private static readonly string LOGTAG = Log.LogTagFromType();
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Type", type switch
+ {
+ Office365MetaType.Users => SourceItemType.MetaRootUsers.ToString(),
+ Office365MetaType.Groups => SourceItemType.MetaRootGroups.ToString(),
+ Office365MetaType.Sites => SourceItemType.MetaRootSites.ToString(),
+ _ => SourceItemType.MetaRoot.ToString()
+ } },
+ { "o365:MetaType", type.ToString() },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ switch (type)
+ {
+ case Office365MetaType.Users:
+ await foreach (var user in provider.RootApi.ListAllUsersAsync(cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+ yield return new UserSourceEntry(provider, Path, user);
+ }
+ break;
+ case Office365MetaType.Groups:
+ await foreach (var group in provider.RootApi.ListAllGroupsAsync(cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+ yield return new GroupSourceEntry(provider, this.Path, group);
+ }
+ break;
+ case Office365MetaType.Sites:
+ await foreach (var site in provider.RootApi.ListAllSitesAsync(cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+ yield return new SiteSourceEntry(provider, this.Path, site);
+ }
+ break;
+ default:
+ Log.WriteWarningMessage(LOGTAG, "MetaSourceEntryEnumerateUnknownType", null, $"Attempted to enumerate unknown meta entry type: {type}");
+ break;
+ }
+ yield break;
+ }
+}
diff --git a/proprietary/Office365/SourceItems/NotebookSectionGroupSourceEntry.cs b/proprietary/Office365/SourceItems/NotebookSectionGroupSourceEntry.cs
new file mode 100644
index 000000000..3df0e7899
--- /dev/null
+++ b/proprietary/Office365/SourceItems/NotebookSectionGroupSourceEntry.cs
@@ -0,0 +1,50 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Utility;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class NotebookSectionGroupSourceEntry(SourceProvider provider, string path, GraphUser user, GraphNotebook notebook, GraphOnenoteSectionGroup sectionGroup)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, sectionGroup.Id)), sectionGroup.CreatedDateTime.FromGraphDateTime(), sectionGroup.LastModifiedDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var section in provider.OnenoteApi.ListSectionGroupSectionsAsync(sectionGroup.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new NotebookSectionSourceEntry(provider, this.Path, section);
+ }
+
+ await foreach (var subSectionGroup in provider.OnenoteApi.ListNotebookSectionGroupsAsync(notebook.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ await foreach (var sectionGroup in provider.OnenoteApi.ListSectionGroupSectionGroupsAsync(sectionGroup.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new NotebookSectionGroupSourceEntry(provider, this.Path, user, notebook, sectionGroup);
+ }
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", sectionGroup.Id },
+ { "o365:Type", SourceItemType.NotebookSectionGroup.ToString() },
+ { "o365:Name", sectionGroup.DisplayName ?? "" },
+ { "o365:DisplayName", sectionGroup.DisplayName ?? "" }
+ }
+ .WhereNotNull()
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value));
+
+}
diff --git a/proprietary/Office365/SourceItems/NotebookSectionSourceEntry.cs b/proprietary/Office365/SourceItems/NotebookSectionSourceEntry.cs
new file mode 100644
index 000000000..cedfd5cbd
--- /dev/null
+++ b/proprietary/Office365/SourceItems/NotebookSectionSourceEntry.cs
@@ -0,0 +1,41 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class NotebookSectionSourceEntry(SourceProvider provider, string path, GraphOnenoteSection section)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, section.Id)), section.CreatedDateTime.FromGraphDateTime(), section.LastModifiedDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var page in provider.OnenoteApi.ListSectionPagesAsync(section.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ if (page.ContentUrl == null)
+ continue;
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, page.Id + ".html"),
+ createdUtc: page.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: page.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.OnenoteApi.GetOnenotePageContentStreamAsync(page.ContentUrl, ct));
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", section.Id },
+ { "o365:Type", SourceItemType.NotebookSection.ToString() },
+ { "o365:Name", section.DisplayName ?? "" },
+ { "o365:DisplayName", section.DisplayName ?? "" }
+ }
+ .Where(kvp => !string.IsNullOrEmpty(kvp.Value))
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value));
+}
diff --git a/proprietary/Office365/SourceItems/NotebookSourceEntry.cs b/proprietary/Office365/SourceItems/NotebookSourceEntry.cs
new file mode 100644
index 000000000..6e7e6dd6c
--- /dev/null
+++ b/proprietary/Office365/SourceItems/NotebookSourceEntry.cs
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class NotebookSourceEntry(SourceProvider provider, string path, GraphUser user, GraphNotebook notebook)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, notebook.Id)), notebook.CreatedDateTime.FromGraphDateTime(), notebook.LastModifiedDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var section in provider.OnenoteApi.ListNotebookSectionGroupsAsync(notebook.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new NotebookSectionGroupSourceEntry(provider, this.Path, user, notebook, section);
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", notebook.Id },
+ { "o365:Type", SourceItemType.Notebook.ToString() },
+ { "o365:Name", notebook.DisplayName ?? "" },
+ { "o365:DisplayName", notebook.DisplayName ?? "" }
+ }
+ .Where(kvp => !string.IsNullOrEmpty(kvp.Value))
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value));
+}
diff --git a/proprietary/Office365/SourceItems/PlannerPlanBucketSourceEntry.cs b/proprietary/Office365/SourceItems/PlannerPlanBucketSourceEntry.cs
new file mode 100644
index 000000000..7b0ac4696
--- /dev/null
+++ b/proprietary/Office365/SourceItems/PlannerPlanBucketSourceEntry.cs
@@ -0,0 +1,41 @@
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class PlannerPlanBucketSourceEntry(SourceProvider provider, string path, GraphPlannerPlan plan)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "buckets")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var bucket in provider.PlannerApi.ListPlannerBucketsAsync(plan.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, bucket.Id + ".json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) =>
+ {
+ var stream = new MemoryStream();
+ JsonSerializer.Serialize(stream, bucket);
+ stream.Position = 0;
+ return Task.FromResult(stream);
+ },
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", bucket.Id },
+ { "o365:Type", SourceItemType.PlannerBucket.ToString() },
+ { "o365:Name", bucket.Name },
+ { "o365:OrderHint", bucket.OrderHint }
+ })
+ );
+ }
+ }
+}
diff --git a/proprietary/Office365/SourceItems/PlannerPlanSourceEntry.cs b/proprietary/Office365/SourceItems/PlannerPlanSourceEntry.cs
new file mode 100644
index 000000000..ce2e98266
--- /dev/null
+++ b/proprietary/Office365/SourceItems/PlannerPlanSourceEntry.cs
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class PlannerPlanSourceEntry(SourceProvider provider, string parentPath, GraphPlannerPlan plan)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, plan.Id)), plan.CreatedDateTime.FromGraphDateTime(), plan.CreatedDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ yield return new PlannerPlanBucketSourceEntry(provider, this.Path, plan);
+ yield return new PlannerPlanTasksSourceEntry(provider, this.Path, plan);
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", plan.Id },
+ { "o365:Type", SourceItemType.Planner.ToString() },
+ { "o365:Name", plan.Title ?? "" },
+ { "o365:Title", plan.Title ?? "" },
+ { "o365:CreatedBy", JsonSerializer.Serialize(plan.CreatedBy) ?? "" },
+ { "o365:Container", JsonSerializer.Serialize(plan.Container) ?? "" },
+ { "o365:Owner", plan.Owner ?? "" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+
+}
diff --git a/proprietary/Office365/SourceItems/PlannerPlanTasksSourceEntry.cs b/proprietary/Office365/SourceItems/PlannerPlanTasksSourceEntry.cs
new file mode 100644
index 000000000..12d0a48bb
--- /dev/null
+++ b/proprietary/Office365/SourceItems/PlannerPlanTasksSourceEntry.cs
@@ -0,0 +1,21 @@
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class PlannerPlanTasksSourceEntry(SourceProvider provider, string path, GraphPlannerPlan plan)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "tasks")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var task in provider.PlannerApi.ListPlannerTasksAsync(plan.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new PlannerTaskSourceEntry(provider, this.Path, task);
+ }
+ }
+}
diff --git a/proprietary/Office365/SourceItems/PlannerTaskSourceEntry.cs b/proprietary/Office365/SourceItems/PlannerTaskSourceEntry.cs
new file mode 100644
index 000000000..84f2b6880
--- /dev/null
+++ b/proprietary/Office365/SourceItems/PlannerTaskSourceEntry.cs
@@ -0,0 +1,55 @@
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class PlannerTaskSourceEntry(SourceProvider provider, string path, GraphPlannerTask task)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, task.Id)), task.CreatedDateTime.FromGraphDateTime(), null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
+ createdUtc: task.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: task.CreatedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.PlannerApi.GetPlannerTaskDetailsStreamAsync(task.Id, ct));
+
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "assignedToTaskBoardFormat.json"),
+ createdUtc: task.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: task.CreatedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.PlannerApi.GetPlannerAssignedToTaskBoardFormatStreamAsync(task.Id, ct));
+
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "bucketTaskBoardFormat.json"),
+ createdUtc: task.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: task.CreatedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.PlannerApi.GetPlannerBucketTaskBoardFormatStreamAsync(task.Id, ct));
+
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "progressTaskBoardFormat.json"),
+ createdUtc: task.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: task.CreatedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.PlannerApi.GetPlannerProgressTaskBoardFormatStreamAsync(task.Id, ct));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", task.Id },
+ { "o365:Type", SourceItemType.UserPlannerTasks.ToString() },
+ { "o365:Name", task.Title },
+ { "o365:Title", task.Title },
+ { "o365:BucketId", task.BucketId },
+ { "o365:PercentComplete", task.PercentComplete?.ToString() },
+ });
+
+
+}
diff --git a/proprietary/Office365/SourceItems/RootSourceEntry.cs b/proprietary/Office365/SourceItems/RootSourceEntry.cs
new file mode 100644
index 000000000..56b8de72f
--- /dev/null
+++ b/proprietary/Office365/SourceItems/RootSourceEntry.cs
@@ -0,0 +1,82 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class RootSourceEntry(SourceProvider provider, string mountPoint)
+ : MetaEntryBase(Util.AppendDirSeparator(mountPoint), null, null)
+{
+ internal sealed record BackupDescription(
+ string Version,
+ string DuplicatiVersion,
+ string MachineId,
+ string PackageTypeId,
+ string OSType,
+ string OSVersion,
+ string DirectorySeparator,
+ string MountPoint
+ );
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Type", SourceItemType.MetaRoot.ToString() },
+ { "o365:Name", "Office 365 Tenant" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var description = new BackupDescription(
+ Version: "1.0",
+ DuplicatiVersion: Library.AutoUpdater.UpdaterManager.SelfVersion.Version ?? "",
+ MachineId: Library.AutoUpdater.DataFolderManager.MachineID,
+ PackageTypeId: Library.AutoUpdater.UpdaterManager.PackageTypeId,
+ OSType: Library.AutoUpdater.UpdaterManager.OperatingSystemName,
+ OSVersion: Library.Utility.OSInfoHelper.PlatformString,
+ DirectorySeparator: System.IO.Path.DirectorySeparatorChar.ToString(),
+ MountPoint: Path
+ );
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "office-365-backup-description.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: async (ct) =>
+ {
+ var ms = new MemoryStream();
+ await System.Text.Json.JsonSerializer.SerializeAsync(ms, description, cancellationToken: ct).ConfigureAwait(false);
+ ms.Seek(0, SeekOrigin.Begin);
+ return ms;
+ },
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "ExtType", "o365"},
+ { "Ext:DuplicatiVersion", description.DuplicatiVersion },
+ { "Ext:MachineId", description.MachineId },
+ { "Ext:PackageTypeId", description.PackageTypeId },
+ { "Ext:OSType", description.OSType },
+ { "Ext:OSVersion", description.OSVersion },
+ { "Ext:DirectorySeparator", description.DirectorySeparator },
+ { "Ext:MountPoint", description.MountPoint }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value))
+ );
+
+ foreach (var type in provider.IncludedRootTypes)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+ yield return new MetaRootSourceEntry(provider, Path, type);
+ }
+ }
+
+ public override Task FileExists(string filename, CancellationToken cancellationToken)
+ => Task.FromResult(Enum.GetValues().Any(x => x.ToString() == filename));
+}
diff --git a/proprietary/Office365/SourceItems/SiteSourceEntry.cs b/proprietary/Office365/SourceItems/SiteSourceEntry.cs
new file mode 100644
index 000000000..1adb2bba9
--- /dev/null
+++ b/proprietary/Office365/SourceItems/SiteSourceEntry.cs
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class SiteSourceEntry(SourceProvider provider, string path, GraphSite site)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, site.Id)), null, null)
+{
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", site.Id },
+ { "o365:Type", SourceItemType.Site.ToString() },
+ { "o365:Name", $"{site.DisplayName}{(site.SiteCollection?.PersonalSite == true ? " (Personal)" : "" )}" },
+ { "o365:DisplayName", site.DisplayName },
+ { "o365:WebUrl", site.WebUrl },
+ { "o365:Hostname", site.SiteCollection?.Hostname },
+ { "o365:PersonalSite", site.SiteCollection?.PersonalSite?.ToString() }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ yield return new StreamResourceEntryFunction(
+ SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.SiteApi.GetSiteMetadataStreamAsync(site.Id, ct)
+ );
+
+ await foreach (var drive in provider.SiteApi.ListSiteDrivesAsync(site.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new DriveSourceEntry(provider, this.Path, drive);
+ }
+ }
+}
diff --git a/proprietary/Office365/SourceItems/SourceItemType.cs b/proprietary/Office365/SourceItems/SourceItemType.cs
new file mode 100644
index 000000000..22a6fd7a7
--- /dev/null
+++ b/proprietary/Office365/SourceItems/SourceItemType.cs
@@ -0,0 +1,46 @@
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+///
+/// The type of the source items found in Office365
+///
+public enum SourceItemType
+{
+ MetaRoot,
+ MetaRootUsers,
+ MetaRootGroups,
+ MetaRootSites,
+ Calendar,
+ CalendarGroup,
+ CalendarEvent,
+ Chat,
+ ChatHostedContent,
+ ChatMessage,
+ ChatMember,
+ Drive,
+ DriveFile,
+ DriveFolder,
+ Group,
+ GroupCalendar,
+ GroupChannel,
+ GroupChannelMessage,
+ GroupChannelMessageReply,
+ GroupConversation,
+ GroupConversationThread,
+ GroupConversationThreadPost,
+ Notebook,
+ NotebookSection,
+ NotebookSectionGroup,
+ Planner,
+ Site,
+ TaskList,
+ TaskListTask,
+ TaskListLinkedResource,
+ TaskListChecklistItem,
+ UserMailbox,
+ UserMailboxFolder,
+ UserMailboxEmail,
+ User,
+ UserContact,
+ UserPlannerTasks,
+ PlannerBucket
+}
\ No newline at end of file
diff --git a/proprietary/Office365/SourceItems/StreamResourceEntryBase.cs b/proprietary/Office365/SourceItems/StreamResourceEntryBase.cs
new file mode 100644
index 000000000..c2b04010f
--- /dev/null
+++ b/proprietary/Office365/SourceItems/StreamResourceEntryBase.cs
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal abstract class StreamResourceEntryBase(string path) : ISourceProviderEntry
+{
+ public bool IsFolder => false;
+
+ public bool IsMetaEntry => false;
+
+ public virtual bool IsRootEntry => false;
+
+ public abstract DateTime CreatedUtc { get; }
+
+ public abstract DateTime LastModificationUtc { get; }
+
+ public string Path => path;
+
+ public abstract long Size { get; }
+ public bool IsSymlink => false;
+
+ public string? SymlinkTarget => null;
+
+ public virtual FileAttributes Attributes => FileAttributes.Normal;
+
+ public bool IsBlockDevice => false;
+
+ public bool IsCharacterDevice => false;
+
+ public bool IsAlternateStream => false;
+ public string? HardlinkTargetId => null;
+
+ public IAsyncEnumerable Enumerate(CancellationToken cancellationToken)
+ => throw new NotSupportedException("Cannot enumerate a file entry");
+
+ public Task FileExists(string filename, CancellationToken cancellationToken)
+ => throw new NotSupportedException("Cannot enumerate a file entry");
+
+ public virtual Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary());
+
+ public abstract Task OpenRead(CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/proprietary/Office365/SourceItems/StreamResourceEntryFunction.cs b/proprietary/Office365/SourceItems/StreamResourceEntryFunction.cs
new file mode 100644
index 000000000..1619509b4
--- /dev/null
+++ b/proprietary/Office365/SourceItems/StreamResourceEntryFunction.cs
@@ -0,0 +1,42 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class StreamResourceEntryFunction(string path, DateTime createdUtc, DateTime lastModificationUtc, long size, Func> streamFactory, Func>>? minorMetadataFactory = null) : StreamResourceEntryBase(path)
+{
+ public override DateTime CreatedUtc => createdUtc;
+
+ public override DateTime LastModificationUtc => lastModificationUtc;
+ public override long Size => _stream == null ? size : _stream.Length;
+ private Stream? _stream;
+
+ public async Task ProbeIfExistsAsync(CancellationToken cancellationToken)
+ {
+ if (_stream != null)
+ return true;
+
+ try
+ {
+ _stream = await streamFactory(cancellationToken).ConfigureAwait(false);
+ return _stream != null;
+ }
+ catch (FileNotFoundException)
+ {
+ return false;
+ }
+ catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ return false;
+ }
+ }
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ => _stream != null
+ ? _stream
+ : await streamFactory(cancellationToken).ConfigureAwait(false);
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => minorMetadataFactory != null
+ ? minorMetadataFactory(cancellationToken)
+ : base.GetMinorMetadata(cancellationToken);
+}
diff --git a/proprietary/Office365/SourceItems/TaskListSourceEntry.cs b/proprietary/Office365/SourceItems/TaskListSourceEntry.cs
new file mode 100644
index 000000000..6332c1e68
--- /dev/null
+++ b/proprietary/Office365/SourceItems/TaskListSourceEntry.cs
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class TaskListSourceEntry(SourceProvider provider, string path, GraphUser user, GraphTodoTaskList taskList)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, taskList.Id)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var task in provider.TodoApi.ListTaskListTasksAsync(user.Id, taskList.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new TaskListTaskSourceEntry(provider, this.Path, user, taskList, task);
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", taskList.Id },
+ { "o365:Type", SourceItemType.TaskList.ToString() },
+ { "o365:Name", taskList.DisplayName ?? "" },
+ { "o365:DisplayName", taskList.DisplayName ?? "" },
+ { "o365:WellknownListName", taskList.WellknownListName ?? "" },
+ { "o365:IsOwner", taskList.IsOwner?.ToString() ?? "" },
+ { "o365:IsShared", taskList.IsShared?.ToString() ?? "" }
+ }
+ .Where(kvp => !string.IsNullOrEmpty(kvp.Value))
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value));
+}
diff --git a/proprietary/Office365/SourceItems/TaskListTaskCheclistItemsSourceEntry.cs b/proprietary/Office365/SourceItems/TaskListTaskCheclistItemsSourceEntry.cs
new file mode 100644
index 000000000..e17d4840d
--- /dev/null
+++ b/proprietary/Office365/SourceItems/TaskListTaskCheclistItemsSourceEntry.cs
@@ -0,0 +1,69 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class TaskListTaskSubitemsSourceEntry(SourceProvider provider, string path, GraphUser user, GraphTodoTaskList taskList, GraphTodoTask task, TaskListTaskType type)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, type.ToString().ToLowerInvariant())), task.CreatedDateTime.FromGraphDateTime(), task.LastModifiedDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (type == TaskListTaskType.ChecklistItems)
+ {
+ await foreach (var checklistItem in provider.TodoApi.ListTaskChecklistItemsAsync(user.Id, taskList.Id, task.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, checklistItem.Id + ".json"),
+ createdUtc: checklistItem.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: task.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.TodoApi.GetTaskChecklistItemStreamAsync(user.Id, taskList.Id, task.Id, checklistItem.Id, ct),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", checklistItem.Id },
+ { "o365:Type", SourceItemType.TaskListChecklistItem.ToString() },
+ { "o365:Name", checklistItem.DisplayName ?? "" },
+ { "o365:IsChecked", checklistItem.IsChecked?.ToString() ?? "" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+ }
+ yield break;
+ }
+
+ if (type == TaskListTaskType.LinkedResources)
+ {
+ await foreach (var linkedResource in provider.TodoApi.ListTaskLinkedResourcesAsync(user.Id, taskList.Id, task.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, linkedResource.Id + ".json"),
+ createdUtc: task.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: task.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.TodoApi.GetTaskLinkedResourceStreamAsync(user.Id, taskList.Id, task.Id, linkedResource.Id, ct),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", linkedResource.Id },
+ { "o365:Type", SourceItemType.TaskListLinkedResource.ToString() },
+ { "o365:Name", linkedResource.DisplayName ?? "" },
+ { "o365:WebUrl", linkedResource.WebUrl ?? "" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+ }
+ yield break;
+ }
+
+ throw new InvalidOperationException($"Unsupported TaskListTaskType: {type}");
+ }
+}
+
diff --git a/proprietary/Office365/SourceItems/TaskListTaskSourceEntry.cs b/proprietary/Office365/SourceItems/TaskListTaskSourceEntry.cs
new file mode 100644
index 000000000..9b2d6b1ae
--- /dev/null
+++ b/proprietary/Office365/SourceItems/TaskListTaskSourceEntry.cs
@@ -0,0 +1,46 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal enum TaskListTaskType
+{
+ ChecklistItems,
+ LinkedResources
+}
+
+internal class TaskListTaskSourceEntry(SourceProvider provider, string path, GraphUser user, GraphTodoTaskList taskList, GraphTodoTask task)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, task.Id)), task.CreatedDateTime.FromGraphDateTime(), task.LastModifiedDateTime.FromGraphDateTime())
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
+ createdUtc: task.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: task.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.TodoApi.GetTaskStreamAsync(user.Id, taskList.Id, task.Id, ct),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", task.Id },
+ { "o365:Type", SourceItemType.TaskListTask.ToString() },
+ { "o365:Name", task.Title ?? "" },
+ { "o365:LastModifiedDateTime", task.LastModifiedDateTime.FromGraphDateTime().ToString("o") },
+ { "o365:CreatedDateTime", task.CreatedDateTime.FromGraphDateTime().ToString("o") },
+ { "o365:Status", task.Status ?? "" },
+ { "o365:Importance", task.Importance ?? "" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+
+ yield return new TaskListTaskSubitemsSourceEntry(provider, this.Path, user, taskList, task, TaskListTaskType.ChecklistItems);
+ yield return new TaskListTaskSubitemsSourceEntry(provider, this.Path, user, taskList, task, TaskListTaskType.LinkedResources);
+
+ }
+}
diff --git a/proprietary/Office365/SourceItems/UserMailboxEmailSourceEntry.cs b/proprietary/Office365/SourceItems/UserMailboxEmailSourceEntry.cs
new file mode 100644
index 000000000..57fc6f760
--- /dev/null
+++ b/proprietary/Office365/SourceItems/UserMailboxEmailSourceEntry.cs
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class UserMailboxEmailSourceEntry(SourceProvider provider, string path, GraphUser user, GraphMessage email)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, email.Id)), email.CreatedDateTime.FromGraphDateTime(), email.LastModifiedDateTime.FromGraphDateTime())
+{
+ private readonly Dictionary _minorMetadata = new Dictionary {
+ { "o365:v", "1" },
+ { "o365:Id", email.Id },
+ { "o365:Type", SourceItemType.UserMailboxEmail.ToString() },
+ { "o365:Name", email.Subject ?? "" },
+ { "o365:Subject", email.Subject ?? "" },
+ { "o365:From", email.From?.EmailAddress?.Address ?? "" },
+ { "o365:To", string.Join(",", email.ToRecipients?.Select(x => x.EmailAddress?.Address) ?? []) },
+ { "o365:CreatedDateTime", email.CreatedDateTime.FromGraphDateTime().ToString("o") },
+ { "o365:ReceivedDateTime", email.ReceivedDateTime.FromGraphDateTime().ToString("o") },
+ { "o365:SentDateTime", email.SentDateTime.FromGraphDateTime().ToString("o") },
+ { "o365:InternetMessageId", email.InternetMessageId ?? "" },
+ { "o365:HasAttachments", email.HasAttachments?.ToString() ?? "" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value);
+
+ public override Task> GetMinorMetadata(CancellationToken ct)
+ => Task.FromResult(_minorMetadata);
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "content.eml"),
+ createdUtc: email.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: email.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.UserEmailApi.GetEmailContentStreamAsync(user.Id, email.Id, ct)
+ );
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"),
+ createdUtc: email.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: email.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => provider.UserEmailApi.GetEmailMetadataStreamAsync(user.Id, email.Id, ct)
+ );
+ }
+}
diff --git a/proprietary/Office365/SourceItems/UserMailboxFolderSourceEntry.cs b/proprietary/Office365/SourceItems/UserMailboxFolderSourceEntry.cs
new file mode 100644
index 000000000..f97e1891b
--- /dev/null
+++ b/proprietary/Office365/SourceItems/UserMailboxFolderSourceEntry.cs
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class UserMailboxFolderSourceEntry(SourceProvider provider, GraphUser user, string path, GraphMailFolder folder)
+ : MetaEntryBase(Util.AppendDirSeparator(path), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var email in provider.UserEmailApi.ListAllEmailsInFolderAsync(user.Id, folder.Id, cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new UserMailboxEmailSourceEntry(provider, this.Path, user, email);
+ }
+
+ await foreach (var childFolder in provider.UserEmailApi.ListMailChildFoldersAsync(user.Id, folder.Id, cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new UserMailboxFolderSourceEntry(
+ provider,
+ user,
+ SystemIO.IO_OS.PathCombine(this.Path, childFolder.Id),
+ childFolder);
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", folder.Id },
+ { "o365:Type", SourceItemType.UserMailboxFolder.ToString() },
+ { "o365:Name", folder.DisplayName ?? "" },
+ { "o365:DisplayName", folder.DisplayName ?? "" },
+ { "o365:ParentFolderId", folder.ParentFolderId ?? "" },
+ { "o365:IsHidden", folder.IsHidden?.ToString() ?? "" },
+ }
+ .Where(kvp => !string.IsNullOrEmpty(kvp.Value))
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value));
+}
diff --git a/proprietary/Office365/SourceItems/UserSourceEntry.cs b/proprietary/Office365/SourceItems/UserSourceEntry.cs
new file mode 100644
index 000000000..415f94aad
--- /dev/null
+++ b/proprietary/Office365/SourceItems/UserSourceEntry.cs
@@ -0,0 +1,51 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal enum Office365UserType
+{
+ Profile,
+ Mailbox,
+ Calendar,
+ Contacts,
+ Tasks,
+ Notes,
+ Planner,
+ Chats
+}
+
+internal class UserSourceEntry(SourceProvider provider, string path, GraphUser user)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, user.Id)), user.CreatedDateTime.FromGraphDateTime(), null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ foreach (var type in provider.IncludedUserTypes)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new UserTypeSourceEntry(provider, Path, user, type);
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "o365:v", "1" },
+ { "o365:Id", user.Id },
+ { "o365:Type", SourceItemType.User.ToString() },
+ { "o365:Name", user.DisplayName ?? "" },
+ { "o365:DisplayName", user.DisplayName ?? "" },
+ { "o365:UserPrincipalName", user.UserPrincipalName ?? "" },
+ { "o365:AccountEnabled", user.AccountEnabled?.ToString() ?? "" },
+ }
+ .Where(kvp => !string.IsNullOrEmpty(kvp.Value))
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value));
+
+ public override Task FileExists(string filename, CancellationToken cancellationToken)
+ => Task.FromResult(provider.IncludedUserTypes.Any(x => x.ToString() == filename));
+}
\ No newline at end of file
diff --git a/proprietary/Office365/SourceItems/UserTypeSourceEntry.cs b/proprietary/Office365/SourceItems/UserTypeSourceEntry.cs
new file mode 100644
index 000000000..f4c0080c1
--- /dev/null
+++ b/proprietary/Office365/SourceItems/UserTypeSourceEntry.cs
@@ -0,0 +1,222 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Logging;
+
+namespace Duplicati.Proprietary.Office365.SourceItems;
+
+internal class UserTypeSourceEntry(SourceProvider provider, string path, GraphUser user, Office365UserType userType)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, userType.ToString().ToLowerInvariant())), user.CreatedDateTime.FromGraphDateTime(), null)
+{
+ private static readonly string LOGTAG = Log.LogTagFromType();
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var entries = userType switch
+ {
+ Office365UserType.Profile => ProfileEntries(cancellationToken),
+ Office365UserType.Mailbox => MailboxEntries(cancellationToken),
+ Office365UserType.Calendar => CalendarEntries(cancellationToken),
+ Office365UserType.Contacts => ContactsEntries(cancellationToken),
+ Office365UserType.Tasks => TasksEntries(cancellationToken),
+ Office365UserType.Notes => NotesEntries(cancellationToken),
+ Office365UserType.Planner => PlannerEntries(cancellationToken),
+ Office365UserType.Chats => ChatsEntries(cancellationToken),
+
+ _ => null
+ };
+
+ if (entries is null)
+ {
+ Log.WriteWarningMessage(LOGTAG, "UnknownUserType", null, $"Unknown user type '{userType}' for user '{user.Id}'");
+ yield break;
+ }
+
+ await foreach (var entry in entries)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ // Probe if the entry exists, if it is a stream resource
+ if (entry is StreamResourceEntryFunction sref)
+ if (!await sref.ProbeIfExistsAsync(cancellationToken).ConfigureAwait(false))
+ continue;
+
+ yield return entry;
+ }
+ }
+
+ private async IAsyncEnumerable ProfileEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var res = new StreamResourceEntryFunction[] {
+ new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "user.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.UserProfileApi.GetUserObjectStreamAsync(user.Id, ct)),
+
+ new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "photo.jpg"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.UserProfileApi.GetUserPhotoStreamAsync(user.Id, ct)),
+
+ new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "licenses.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.UserProfileApi.GetUserLicenseDetailsStreamAsync(user.Id, ct)),
+
+ new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "manager.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.UserProfileApi.GetUserManagerStreamAsync(user.Id, ct)),
+
+ new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "teams.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => provider.UserProfileApi.GetUserTeamMembershipStreamAsync(user.Id, ct)),
+
+ };
+
+ foreach (var entry in res)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return entry;
+ }
+ }
+
+ private async IAsyncEnumerable MailboxEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var rootFolder = await provider.UserEmailApi.GetMailRootFolderAsync(user.Id, cancellationToken).ConfigureAwait(false);
+
+ // Don't introduce another level for the root folder, just enumerate its contents here
+ var p = new UserMailboxFolderSourceEntry(provider, user, this.Path, rootFolder);
+ await foreach (var entry in p.Enumerate(cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return entry;
+ }
+ }
+
+ private async IAsyncEnumerable ContactsEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var contact in provider.ContactsApi.ListAllContactsAsync(user.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ var ms = new MemoryStream();
+ System.Text.Json.JsonSerializer.Serialize(ms, contact);
+ ms.Seek(0, SeekOrigin.Begin);
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, contact.Id + ".json"),
+ createdUtc: contact.CreatedDateTime.FromGraphDateTime(),
+ lastModificationUtc: contact.LastModifiedDateTime.FromGraphDateTime(),
+ size: -1,
+ streamFactory: (ct) => Task.FromResult(ms),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", contact.Id },
+ { "o365:Type", SourceItemType.UserContact.ToString() },
+ { "o365:Name", contact.DisplayName },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value)));
+ }
+ }
+
+ private async IAsyncEnumerable TasksEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+
+ await foreach (var taskList in provider.TodoApi.ListUserTaskListsAsync(user.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new TaskListSourceEntry(provider, this.Path, user, taskList);
+ }
+ }
+
+ private async IAsyncEnumerable NotesEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var note in provider.OnenoteApi.ListUserNotebooksAsync(user.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new NotebookSourceEntry(provider, this.Path, user, note);
+ }
+ }
+
+ // private async IAsyncEnumerable OneDriveEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ // {
+ // await foreach (var drive in provider.OneDriveApi.ListUserDrivesAsync(user.Id, cancellationToken).ConfigureAwait(false))
+ // {
+ // if (cancellationToken.IsCancellationRequested)
+ // yield break;
+
+ // yield return new DriveSourceEntry(provider, this.Path, drive);
+ // }
+ // }
+
+ private async IAsyncEnumerable CalendarEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var calendar in provider.CalendarApi.ListUserCalendarGroupsAsync(user.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new CalendarGroupSourceEntry(provider, this.Path, user, calendar);
+ }
+ }
+
+ private async IAsyncEnumerable PlannerEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var plan in provider.UserPlannerApi.ListUserAssignedPlannerTasksAsync(user.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ var ms = new MemoryStream();
+ System.Text.Json.JsonSerializer.Serialize(ms, plan);
+ ms.Seek(0, SeekOrigin.Begin);
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, plan.Id + ".json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: (ct) => Task.FromResult(ms),
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "o365:v", "1" },
+ { "o365:Id", plan.Id },
+ { "o365:Type", SourceItemType.UserPlannerTasks.ToString() },
+ { "o365:Name", plan.Title },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value))
+ );
+ }
+ }
+
+ private async IAsyncEnumerable ChatsEntries([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var chat in provider.ChatApi.ListUserChatsAsync(user.Id, cancellationToken).ConfigureAwait(false))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new ChatSourceEntry(provider, this.Path, chat);
+ }
+ }
+}
\ No newline at end of file
diff --git a/proprietary/Office365/SourceProvider/SourceProvider.Group.cs b/proprietary/Office365/SourceProvider/SourceProvider.Group.cs
new file mode 100644
index 000000000..386c10c22
--- /dev/null
+++ b/proprietary/Office365/SourceProvider/SourceProvider.Group.cs
@@ -0,0 +1,527 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.CommandLine;
+using System.Net.Http.Headers;
+
+namespace Duplicati.Proprietary.Office365;
+
+partial class SourceProvider
+{
+ internal GroupApiImpl GroupApi => new GroupApiImpl(_apiHelper);
+
+ internal class GroupApiImpl(APIHelper provider)
+ {
+ public Task GetGroupMetadataStreamAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select =
+ "id,displayName,description,mail,mailEnabled,securityEnabled,visibility," +
+ "groupTypes,resourceProvisioningOptions,createdDateTime";
+
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+
+ public IAsyncEnumerable ListGroupMembersAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/members" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ public IAsyncEnumerable ListGroupOwnersAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/owners" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+ }
+
+ internal GroupConversationApiImpl GroupConversationApi => new GroupConversationApiImpl(_apiHelper);
+
+ internal sealed class GroupConversationApiImpl(APIHelper provider)
+ {
+ ///
+ /// Lists the group conversations (mailbox-like “threads of discussion”).
+ /// GET /groups/{id}/conversations
+ ///
+ internal IAsyncEnumerable ListGroupConversationsAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/conversations" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ ///
+ /// Lists all threads in a group.
+ /// GET /groups/{id}/threads
+ ///
+ internal IAsyncEnumerable ListGroupThreadsAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/threads" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ ///
+ /// Lists threads for a specific conversation.
+ /// GET /groups/{id}/conversations/{conversationId}/threads
+ ///
+ internal IAsyncEnumerable ListConversationThreadsAsync(
+ string groupId,
+ string conversationId,
+ CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+ var conv = Uri.EscapeDataString(conversationId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/conversations/{conv}/threads" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ ///
+ /// Lists posts in a thread.
+ /// GET /groups/{id}/threads/{threadId}/posts
+ /// (Also works as /groups/{id}/conversations/{conversationId}/threads/{threadId}/posts)
+ ///
+ internal IAsyncEnumerable ListThreadPostsAsync(string groupId, string threadId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+ var thread = Uri.EscapeDataString(threadId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/threads/{thread}/posts" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ ///
+ /// Returns a post (message) as JSON stream.
+ /// GET /groups/{id}/threads/{threadId}/posts/{postId}
+ ///
+ internal Task GetThreadPostStreamAsync(string groupId, string threadId, string postId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+ var thread = Uri.EscapeDataString(threadId);
+ var post = Uri.EscapeDataString(postId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/threads/{thread}/posts/{post}" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+ }
+
+ internal GroupCalendarApiImpl GroupCalendarApi => new GroupCalendarApiImpl(_apiHelper);
+
+ internal sealed class GroupCalendarApiImpl(APIHelper provider)
+ {
+ internal Task GetGroupCalendarStreamAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/calendar" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+
+ internal Task GetGroupCalendarAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/calendar" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphItemAsync(url, ct);
+ }
+
+ internal IAsyncEnumerable ListGroupEventsAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+
+ // Underlying objects (series masters + single instances)
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/events" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ internal Task GetGroupEventStreamAsync(string groupId, string eventId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+ var ev = Uri.EscapeDataString(eventId);
+
+ var select =
+ "id,subject,createdDateTime,lastModifiedDateTime,start,end,isAllDay,isCancelled," +
+ "location,organizer,attendees,recurrence,seriesMasterId,type," +
+ "body,bodyPreview,importance,sensitivity,categories,showAs,responseStatus,onlineMeeting,webLink";
+
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/events/{ev}" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+ }
+
+ internal DriveApiImpl GroupDriveApi => new DriveApiImpl(_apiHelper);
+
+ internal sealed class DriveApiImpl(APIHelper provider)
+ {
+ internal IAsyncEnumerable ListGroupDrivesAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/drives" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+ }
+
+ internal GroupPlannerApiImpl GroupPlannerApi => new GroupPlannerApiImpl(_apiHelper);
+
+ internal sealed class GroupPlannerApiImpl(APIHelper provider)
+ {
+ internal IAsyncEnumerable ListGroupPlannerPlansAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/groups/{group}/planner/plans" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+ }
+
+ internal PlannerApiImpl PlannerApi => new PlannerApiImpl(_apiHelper);
+
+ internal sealed class PlannerApiImpl(APIHelper provider)
+ {
+ internal IAsyncEnumerable ListPlannerBucketsAsync(string planId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var plan = Uri.EscapeDataString(planId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/planner/plans/{plan}/buckets" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ internal IAsyncEnumerable ListPlannerTasksAsync(string planId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var plan = Uri.EscapeDataString(planId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+
+ var url =
+ $"{baseUrl}/v1.0/planner/plans/{plan}/tasks" +
+ $"?$select={Uri.EscapeDataString(select)}" +
+ $"&$top={GENERAL_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ internal Task GetPlannerTaskDetailsStreamAsync(string taskId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var task = Uri.EscapeDataString(taskId);
+
+ // Task details: description + checklist + references + previewType
+ var select = "id,description,checklist,references,previewType";
+ var url =
+ $"{baseUrl}/v1.0/planner/tasks/{task}/details" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+
+ internal Task GetPlannerProgressTaskBoardFormatStreamAsync(string taskId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var task = Uri.EscapeDataString(taskId);
+
+ var select = "id,orderHint";
+ var url =
+ $"{baseUrl}/v1.0/planner/tasks/{task}/progressTaskBoardFormat" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+
+ internal Task GetPlannerAssignedToTaskBoardFormatStreamAsync(string taskId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var task = Uri.EscapeDataString(taskId);
+
+ var select = "id,orderHintsByAssignee";
+ var url =
+ $"{baseUrl}/v1.0/planner/tasks/{task}/assignedToTaskBoardFormat" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+
+ internal Task GetPlannerBucketTaskBoardFormatStreamAsync(string taskId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var task = Uri.EscapeDataString(taskId);
+
+ var select = "id,orderHint";
+ var url =
+ $"{baseUrl}/v1.0/planner/tasks/{task}/bucketTaskBoardFormat" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+ }
+
+ internal GroupTeamsApiImpl GroupTeamsApi => new GroupTeamsApiImpl(_apiHelper);
+
+ internal class GroupTeamsApiImpl(APIHelper provider)
+ {
+ public Task GetGroupTeamStreamAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var group = Uri.EscapeDataString(groupId);
+
+ var select =
+ "id,displayName,description,createdDateTime,visibility,internalId,isArchived,webUrl";
+
+ var url =
+ $"{baseUrl}/v1.0/teams/{group}" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+
+ internal Task GetTeamMetadataStreamAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var team = Uri.EscapeDataString(groupId);
+
+ var select = "id,displayName,description,createdDateTime,visibility,isArchived,webUrl,internalId";
+ var url =
+ $"{baseUrl}/v1.0/teams/{team}" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+
+ internal IAsyncEnumerable ListTeamMembersAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var team = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect(
+ // These will be returned but cannot be selected in the API call
+ exclude: [nameof(GraphTeamMember.Email), nameof(GraphTeamMember.UserId)]
+ );
+
+ var url =
+ $"{baseUrl}/v1.0/teams/{team}/members" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ internal IAsyncEnumerable ListTeamChannelsAsync(string groupId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var team = Uri.EscapeDataString(groupId);
+
+ var select = GraphSelectBuilder.BuildSelect();
+ var url =
+ $"{baseUrl}/v1.0/teams/{team}/channels" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetAllGraphItemsAsync(url, ct);
+ }
+
+ internal Task GetTeamChannelStreamAsync(string groupId, string channelId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var team = Uri.EscapeDataString(groupId);
+ var channel = Uri.EscapeDataString(channelId);
+
+ var select = "id,displayName,description,membershipType,createdDateTime,isFavoriteByDefault,email,webUrl";
+ var url =
+ $"{baseUrl}/v1.0/teams/{team}/channels/{channel}" +
+ $"?$select={Uri.EscapeDataString(select)}";
+
+ return provider.GetGraphAsStreamAsync(url, "application/json", ct);
+ }
+
+ internal IAsyncEnumerable ListChannelMessagesAsync(string groupId, string channelId, CancellationToken ct)
+ {
+ var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
+ var team = Uri.EscapeDataString(groupId);
+ var channel = Uri.EscapeDataString(channelId);
+
+ var url =
+ $"{baseUrl}/v1.0/teams/{team}/channels/{channel}/messages" +
+ $"?$top={OptionsHelper.CHATS_PAGE_SIZE}";
+
+ return provider.GetAllGraphItemsAsync